From a13a482d9b29c2e3706043d52c60bb6f0d23e28b Mon Sep 17 00:00:00 2001 From: Phil Merrell Date: Tue, 21 Jul 2026 12:59:36 -0600 Subject: [PATCH] Release/1.10.0 (#715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(memory): Memory Spaces data layer (PR-1) Adds the F5 Memory Space primitive's data layer — no runtime wiring, gated by MEMORY_SPACES_ENABLED (default off). Backend (apis/shared/memory/): - store.py: S3 content-addressed byte store (sibling of the skills store) - models.py: MemorySpace / MemoryIndex / MemoryEntryRef / SpaceMember - templates.py: Blank / Chief-of-Staff / Research-Notebook presets - repository.py: dedicated memory-spaces table CRUD (META/INDEX/MEMBER rows, OwnerIndex + MemberIndex GSIs, Decimal handling) - service.py: permission-gated lifecycle + sharing + entry/index I/O, resolve_permission chokepoint (viewer reads, editor writes, owner shares/deletes), content-addressed writes with GC-on-replace - feature_flags.py: memory_spaces_enabled() (default off) - 47 moto-backed tests; import boundaries clean Infrastructure: - MemorySpacesConstruct: S3 bucket + dedicated memory-spaces DynamoDB table (OwnerIndex/MemberIndex GSIs), a per-domain table matching the project's actual pattern - Threaded via PlatformComputeRefs to both compute roles (readwrite S3 + DynamoDB); env vars S3_MEMORY_SPACES_BUCKET_NAME / DYNAMODB_MEMORY_SPACES_TABLE_NAME / MEMORY_SPACES_ENABLED - config.ts flag default-off; resource-count assertions updated - tsc + 429 jest tests pass Spec updated to reflect the dedicated-table + two-GSI decisions. Co-Authored-By: Claude Opus 4.8 * docs(memory): re-slice phasing into primitive vs. agent-consumption workstreams Splits the plan into two workstreams to keep the Memory Space a clean bindable primitive: Workstream A (this epic) delivers the primitive + the user-facing "own your data" surface (data layer, app-api CRUD, export, sharing, SPA panel, consolidation); Workstream B (Agent/Harness layer) delivers agent-consumption — the memory_* tools, declarative binding, and system-prompt index injection — so any run surface can bind the same primitive rather than welding it to inference-api. Co-Authored-By: Claude Opus 4.8 * feat(memory): Memory Spaces user surface — /memory/spaces CRUD (A2) Workstream A2 of the re-sliced memory epic: the user-facing "own your data" surface over the Memory Space primitive. No agent-consumption (tools / binding / prompt injection) — that's the Agent/Harness workstream. app-api (apis/app_api/memory_spaces/): - routes.py: /memory/spaces CRUD over MemorySpaceService — list (with templates + accurate per-space role), create-from-template, get (index + entry manifest), delete-or-leave, entry read/list/upsert/delete, index read/update. Sync handlers (FastAPI threadpools the sync boto3 service). - Gated by require_memory_spaces_user: 404 while MEMORY_SPACES_ENABLED off (surface behaves as unmounted); cookie auth via get_current_user_from_session. - Service errors translated NotFound->404, Permission->403, Error->400. - models.py: camelCase request/response models. - Mounted before the existing /memory (AgentCore Memory) router; paths are non-overlapping (/memory/spaces vs /memory/{record_id}). shared service: - leave_space(): a member drops their own grant (the shared-in forget-me case); owner cannot leave. - list_spaces_for_user() now returns (space, role) so shared-in spaces carry the member's real viewer/editor grant (only consumer is the new route). Tests: 12 route tests (moto-backed real service, flag gate, CRUD, 403/404, member-leaves-via-delete) + leave_space service tests. Full memory suite + import boundaries green (69 passing). Co-Authored-By: Claude Opus 4.8 * feat(memory): Memory Space zip export — /memory/spaces/{id}/export (A3) The "own your data" leg of Workstream A: a loss-free `.zip` download of a space's raw markdown (§9). `MemorySpaceService.export_space` gathers the corpus once (index + every entry's bytes) behind the viewer+ permission gate, including the member grant list only for editor+ callers (mirrors `list_members`). The app-api route builds the archive in a `SpooledTemporaryFile` — spilling to disk beyond 8 MiB so a large space never pins memory — and streams it back. Zip mirrors the S3 layout (`{name}/MEMORY.md`, `entries//.md`, `metadata.json`) so it is self-contained and re-importable later. Archive path components are sanitized against zip-slip. Route tests cover layout, verbatim frontmatter, owner-vs-viewer member disclosure, 403/404/flag-off, and the hostile-slug case. Co-Authored-By: Claude Opus 4.8 * feat(memory): Memory Space sharing + optimistic manifest concurrency (A4) The sharing leg of Workstream A, plus the concurrency guarantee that makes multi-editor spaces safe. Sharing surface (app-api, over the existing service grant methods): - GET /memory/spaces/{id}/shares list grants (editor+) - POST /memory/spaces/{id}/shares grant viewer|editor (owner) - PATCH /memory/spaces/{id}/shares/{email} change a grant's role (owner) - DELETE /memory/spaces/{id}/shares/{email} revoke (owner, idempotent) New `MemorySpaceService.update_share` gives PATCH proper not-found semantics and preserves the grant's original createdAt (distinct from share's upsert). Optimistic manifest concurrency (the real design content): - `MemorySpaceRepository.put_index(expected_version=…)` does a conditional DynamoDB write on the manifest `version`, raising the repository-local `OptimisticLockError` on a mismatch. - `write_entry`/`delete_entry` route through a new `_mutate_index` helper: a bounded read-modify-conditional-write retry loop. Because an entry write touches a single slug, re-reading the fresh manifest and re-applying is safe; it converges on transient races and raises `MemorySpaceConcurrencyError` (→ 409) only on a sustained one. Behavior is unchanged for single-writer spaces. Tests: 6 route tests (share CRUD, member gains access, non-owner 403, viewer can't list, PATCH-unknown 404, owner-role 422) + 7 service tests (update_share role/origin/owner-gate + version-increments, stale-write rejected, retry converges, gives-up-after-max). 76 memory + import-boundary tests green. Co-Authored-By: Claude Opus 4.8 * feat(memory): Memory Spaces SPA panel — list/detail/create/share/export (A5) The user-facing "Memory" surface for the Memory Space primitive, under frontend/ai.client/src/app/memory-spaces/. Makes A2–A4 visible to users. - List page: owned + shared-in spaces as cards with role/template badges; per-card open, share (owner), download .zip, delete/leave. Empty, loading, error, and feature-unavailable states. - Detail page: view/edit the MEMORY.md index (editor+) and the entry list; entries open in a dialog to view (viewer) or edit/create (editor+); delete per entry. Header carries share/download/delete-or-leave. - Create-from-template dialog and a share dialog (add-by-email + per-row role + delta-on-save over the A4 /shares endpoints). Viewer access is read-only throughout; the share dialog fails soft for non-owners. - Signal facade (MemorySpaceService) + thin API service mirror the assistants/schedules pattern. The nav entry rides a live accessible$ probe: a 404 (MEMORY_SPACES_ENABLED off) hides it, matching showSchedules. - Routes memory-spaces + memory-spaces/:id (authGuard); redesign-tokens and @angular/cdk/dialog conventions throughout. Facade spec (7 tests) green; dev build + tsc clean; sidenav specs still pass. Co-Authored-By: Claude Opus 4.8 * test(memory): mock MemorySpaceService in sidenav spec (fix unhandled rejection) The A5 sidenav now injects MemorySpaceService and probes `loadSpaces()` in the auth effect. The sidenav spec mocked ScheduleService but not the new service, so the authenticated-probe test constructed the real MemorySpaceService, which fired a real XHR to /memory/spaces (status 0, no backend); `loadSpaces` then re-threw, surfacing as a Vitest "unhandled rejection" at suite level. Mock MemorySpaceService exactly as ScheduleService is mocked, and add matching coverage: probe fires once authenticated (not while unauthenticated) and the `showMemorySpaces` nav gate resolves null→false, false→false, true→true. Full suite: 1422 passed, 0 errors. Co-Authored-By: Claude Opus 4.8 * fix(memory): wire memory-spaces table/bucket names onto app-api app-api owns the Memory Spaces CRUD surface (`/memory/spaces/*`) but its container environment only set MEMORY_SPACES_ENABLED — never the table or bucket names the service reads (DYNAMODB_MEMORY_SPACES_TABLE_NAME / S3_MEMORY_SPACES_BUCKET_NAME). Without them the repository falls back to the default "memory-spaces" table name, which doesn't exist, so every read throws a boto3 ResourceNotFoundException that the centralized handler maps to a 502 "Upstream service error." (inference-api already sets the identical trio, but per the service-boundary rule it isn't the one serving these routes.) Thread `refs.memorySpacesTable`/`.memorySpacesBucket` through AppApiSsmParams and emit both names next to the flag in buildAppApiEnvironment. Names are always wired (read lazily); only MEMORY_SPACES_ENABLED gates route mounting, so flipping the switch on later needs no env change. New unit test guards the wiring; tsc + 431 infra jest tests green. Co-Authored-By: Claude Opus 4.8 * docs(cdk): capture the "wire resource name to every compute" rule Fold the lesson from the app-api env-wiring fix into the cdk-infrastructure skill so the next construct-author sees it while wiring, not after a 502. Adds a "Cross-Construct References" subsection: set a resource's name env var on every compute that reads it (one doesn't imply the other), the silent-502 failure mode (default-name fallback → ResourceNotFoundException → generic 502, invisible to synth/CI), and the env-map test guard + service-boundary caveat. Co-Authored-By: Claude Opus 4.8 * feat(memory): deterministic consolidation health pass (A6) The safe, non-LLM slice of Workstream A6. `MemorySpaceService.consolidate` (editor+) + `POST /memory/spaces/{id}/consolidate` → a `ConsolidationReport`. Auto-fixes only storage hygiene: orphaned content-addressed objects — keys under a space's prefix that no manifest entry or the index pointer references (leaks from crashed/raced writes) — are GC'd (new `MemorySpaceStore.list_keys` drives it). Everything that needs a judgment call is *reported, not mutated*: - duplicate content across slugs (same content hash) — which slug survives is semantic, so it's flagged, never auto-merged; - dead `[[slug]]` wikilinks in MEMORY.md — reported; opt-in `stripDeadLinks` unlinks them (they point nowhere) while preserving the surrounding prose; - over-cap entry counts (`MEMORY_SPACE_INDEX_CAP`, default 200) — flagged, never auto-evicted. This deliberately does not merge/evict/rewrite durable memory — that's deferred to the LLM consolidation pass (Workstream B era), which extends this exact `consolidate()` seam once agentic writes create real duplication/staleness to act on. On-demand only for now; scheduler/threshold auto-run and SPA surfacing are follow-ups. Tests: 8 service (healthy report, orphan GC + skip, dup-report-no-merge, dead-link report + strip-keeps-prose, over-cap flag, editor gate) + 4 route (report shape, no-body, viewer 403, flag-off 404) + 3 store (list_keys prefix scoping / empty / disabled). 104 memory + boundary tests green. Co-Authored-By: Claude Opus 4.8 * docs(agent): Agent Designer spec — unified primitive-binding surface Captures the strategy for the "Agent Designer" (Agent Harness Editor): a new authoring surface that composes an Agent from RBAC-governed primitives (instructions, model, KBs, tools, skills, Memory Spaces, + future), replacing the term/feature "Assistant." Locks the load-bearing decisions: own a primitive-agnostic Agent contract and federate AgentCore Registry later rather than build on it (adopt-with-boundary precedent); evolve the assistant store in place (no parallel table); a uniform bindings[] model with the model as a governed single-select; RBAC = compose the five existing per-primitive access checks (incl. ModelAccessService), not a new system; design-time filter + run-time re-resolution per invoker with block-on- missing v1; ship memory-consumption as a thin vertical slice before the full Designer. Phasing 0–5 + later AWS federation; supersedes the memory spec's "extend the Assistant" §B1 framing. Co-Authored-By: Claude Opus 4.8 * feat(agents): Agent contract + compat mapping in shared assistants models Phase 1 (PR-1) of the Agent Designer. Pure library, zero behavior change: legacy Assistants read unchanged and no caller passes the new fields yet. - AgentModelConfig (D3 governed single-select; field is model_settings/ alias modelConfig to dodge pydantic's reserved model_config — R3) - AgentBinding (open kind on read, KNOWN_BINDING_KINDS for request validation) - optional model_settings + bindings on Assistant (additive) - compat.effective_bindings/to_agent_view (D2): absent bindings synthesize a knowledge_base binding reffing the assistant id (KB's only stable identity, F4 deferred — R4); absent model maps to None, never fabricated (R1) - Decimal-safe serialization for modelConfig.params floats Co-Authored-By: Claude Opus 4.8 * feat(agents): persist bindings + modelConfig with design-time validation Phase 1 (PR-2). The Agent fields now round-trip through the rag-assistants store and are validated at write time by composing existing RBAC checks (D4). Legacy clients are unaffected: the SPA sends none of the new fields and the AssistantResponse surface is unchanged. - service.create_assistant/update_assistant thread bindings + model_settings; to_ddb_safe on write / from_ddb on read so modelConfig.params floats survive DynamoDB (Decimal); explicit [] replaces bindings, absent leaves them untouched - app_api/agents/services/binding_validation.py composes model access (ModelAccessService), memory resolve_permission (viewer+/editor+), the implicit-KB rejection, and inert shape-only checks for tool/skill (D4/D5) - assistants POST/PUT validate then pass through; validation raises 4xx outside the create handler's generic except so it isn't masked as 500 - tests: validation matrix (incl. inert no-RBAC guarantee), persistence round trip, legacy no-field read Co-Authored-By: Claude Opus 4.8 * feat(agents): /agents alias router behind AGENTS_API_ENABLED (dark) Phase 1 (PR-3). A governed Agent read/write surface over the evolved assistant store: same shared service functions and identity-based access gates as /assistants, but returning the Agent shape (compat.to_agent_view -> AgentResponse) so callers see modelConfig + bindings. Legacy ids valid unchanged. - feature_flags.agents_enabled(): AGENTS_API_ENABLED, default OFF (memory-spaces pattern) — surface 404s while off, ships incrementally, /assistants unaffected - app_api/agents/routes.py: require_agents_enabled 404-gate; draft/create/list/ get/update/delete + 4 shares endpoints, delegating to apis.shared.assistants service and reprojecting via to_agent_view; create/update run binding_validation - AgentResponse/AgentsListResponse/AgentSharesResponse (agentId == assistantId) - main.py mounts the router - test-chat + document sub-routes deliberately excluded (would force a 2nd architecture import-boundary exception); list is owner+shared (public/pagination parity deferred to the Phase-4 Designer) - tests: 404-gate, agentId/bindings projection, CRUD permission gating, shares Co-Authored-By: Claude Opus 4.8 * feat(agents): wire AGENTS_API_ENABLED through CDK + Phase 1 docs Phase 1 (PR-4). Completes Phase 1 deployability: the /agents surface can now be turned on per environment. No new AWS resources — the flag only gates whether the routes 404 (the assistant store it reads is always present). - config.ts: AgentsConfig { enabled }; CDK_AGENTS_API_ENABLED (default off, empty-string-safe) or an `agents.enabled` cdk.json context, mirroring memorySpaces exactly - app-api-environment.ts: AGENTS_API_ENABLED env on app-api - infra tests: default-off / opt-on assertion; mock-config default - docs: agent-designer.md Phase-1 status (+ the two refinements and the Oliver-dogfood-gated-on-Phase-3 note); CHANGELOG [Unreleased] The live Oliver dogfood (D6) is deliberately NOT included: it needs Phase 3 harness resolution + Memory Spaces deployed to the target env before a memory_space binding resolves at invocation. Tracked as the Phase 3 payoff. Co-Authored-By: Claude Opus 4.8 * feat(agents): thread AGENTS_API_ENABLED to the inference runtime (Phase 3 PR-0) Phase 3 harness resolution runs inside inference-api, so the runtime needs the same flag the app-api surface got in #593. Default off, mirrors the app-api wiring; without it the harness ignores Agent bindings entirely (today's behavior). Co-Authored-By: Claude Opus 4.8 * feat(agents): resolve Agent modelConfig at invocation, per invoker (Phase 3 PR-A) The Harness now re-resolves an Agent's governed modelConfig against the INVOKING user (D5) and applies it to model selection. Absent modelConfig ⇒ the model resolves exactly as today; gated on AGENTS_API_ENABLED (off in all envs still). - agent_binding_resolver.py: resolve_agent_invocation() checks the pinned model against AppRoleService.can_access_model for the invoker (R2 — same gate the harness uses elsewhere), returns a model_override or raises AgentBindingBlockedError. inference-api imports apis.shared only (boundary-safe) - routes.py /invocations: resolve after assistant load, before the KB search; on block, stream a conversational stream_error via stream_conversational_message (D5 block-with-message, no silent downgrade). Override wins at model resolution; agent params sit beneath request params, still flowing through admin bounds/locks - tests: allowed→override, denied→block (checked vs invoker), no-modelConfig→ empty plan (no RBAC call); 57 existing inference/chat tests unchanged Co-Authored-By: Claude Opus 4.8 * feat(agents): Memory-Space hydration helper for prompt injection (Phase 3 PR-B) Shared, sync helper that resolves a memory_space binding's alwaysLoad specs into injectable text fragments — the read side of Workstream B. - resolve_always_load(): MEMORY.md → index; latest:/ → most-recent matching manifest entry (defines that scheme, which had no resolver); bare slug → entry. Missing entries skipped (never fails a turn). Byte-budgeted with a truncation marker pointing at memory_read (MEMORY_INJECTION_MAX_BYTES, ~24KB) - render_memory_block(): delimited system-prompt block; empty for a fresh space - reads go through MemorySpaceService (re-checks viewer+ internally) — no leak - 11 unit tests against a fake service Co-Authored-By: Claude Opus 4.8 * feat(agents): inject bound Memory Space into the prompt, per invoker (Phase 3 PR-C) The Harness now resolves an Agent's memory_space binding against the invoking user and injects the space's alwaysLoad content (read-only) into the system prompt — the first half of the Workstream B / Oliver payoff. - agent_binding_resolver: _resolve_memory() checks the invoker's grant via MemorySpaceService.resolve_permission (D4); blocks (D5) when the flag is off, the space is gone, or a readwrite binding meets a below-editor invoker (no silent read-only downgrade). Returns ResolvedMemoryBinding (v1: first binding) - routes.py: after prompt assembly, hydrate via resolve_always_load (asyncio.to_ thread; MemorySpaceService re-checks viewer+) and append render_memory_block; best-effort — a memory-read hiccup never fails the turn - tests: memory grant matrix (none/flag-off/missing/read-viewer/readwrite-viewer- block/readwrite-editor/invoker-identity); 78 inference+compat tests green Co-Authored-By: Claude Opus 4.8 * fix(agents): rename app_api.agents package to avoid shadowing top-level agents run-app-api.sh launches app-api with `cd src/apis/app_api && python main.py`, putting that directory on sys.path[0]. The new apis/app_api/agents/ package (Agent Designer surface, #591/#592) then shadowed the top-level `agents` package, so `admin/quota/routes.py`'s `from agents.main_agent...` resolved into it and crashed startup with `ModuleNotFoundError: No module named 'agents.main_agent'`. Tests never caught it — pytest runs from backend/ where `agents` resolves correctly. Production is unaffected (the container runs `uvicorn apis.app_api.main:app` from WORKDIR /app, so sys.path[0] is /app). Rename the package apis/app_api/agents → apis/app_api/agent_designer (and its test dir) so its name can't collide with the top-level `agents` package. Pure rename: the /agents URL surface, router, and behavior are unchanged. Verified: `import agents.main_agent.quota.repository` and the full app-api module now load from src/apis/app_api; 34 agent/boundary tests pass. Co-Authored-By: Claude Opus 4.8 * feat(agents): memory_* tools scoped to an Agent's bound Memory Space (Phase 3) Completes the Workstream B write side: an Agent with a memory_space binding now gets memory_list / memory_read (always) and memory_write (readwrite bindings only) at invocation — Oliver can read AND write his space. - agents/builtin_tools/memory_spaces/: closure-scoped factories capturing the binding's space id + invoker identity, MemorySpaceService via asyncio.to_thread (artifact-tools pattern). Every call re-checks the grant inside the service (viewer+ read / editor+ write), so a revoked grant becomes an error tool-result mid-session, never a leak - routes.py _build_memory_tools(): appended to the extra_tools seam only when a memory binding resolved; write tool gated on access==readwrite. extra_tools agents are never cached → tools closed over user A can't be served to user B - not gated on enabled_tools: the governing capability is the Agent's binding, not the user's tool picker (same reasoning as artifact tools) - tests: tool success/permission-error/not-found matrix + seam counts (none=0, read=2, readwrite=3); 84 inference+tool tests green Co-Authored-By: Claude Opus 4.8 * chore(memory): default Memory Spaces ON with a kill switch Memory Spaces is a complete feature (CRUD + SPA panel + agent binding), so it should ship enabled for every deployer/forker — opt-out, not opt-in — matching the kbSync / scheduledRuns convention. The table + bucket are already provisioned unconditionally in PlatformStack, so this only flips the runtime MEMORY_SPACES_ENABLED env var; no new infra footprint. - config.ts: memorySpaces.enabled default true (empty/unset workflow var = on, only literal "false" disables); interface + block docs updated - platform.yml: forward CDK_MEMORY_SPACES_ENABLED (kill switch) and CDK_AGENTS_API_ENABLED (per-env enable for the still-default-off /agents surface, so dev can turn it on for dogfooding without a code change) - app-api-environment.ts: comment reflects default-on - config.test.ts: 5 tests locking default-on + kill-switch + context override Agent Designer (AGENTS_API_ENABLED) stays default OFF until the Phase-4 Designer UI ships — a headless /agents surface helps no forker. Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): Phase 4 — Agent Designer UI + bindable catalog API Ships the Agent Designer authoring surface (Phase 4) and its Phase-2 precursor, the bindable-primitives catalog. The pickers can't exist without the catalog, so both land together. Backend (Phase 2 catalog): - GET /agents/bindable?kind=model|tool|skill|knowledge_base|memory_space returns an RBAC-filtered palette, composing the 5 existing per-primitive access services (D4); no new RBAC invented. Uniform BindableItem shape so every picker consumes one contract. Route declared before /{agent_id} so the literal path isn't captured. knowledge_base → empty (welded/synthesized); skill/memory_space → empty when their feature flag is off. Behind AGENTS_API_ENABLED. - Fix binding_validation._validate_model: it resolved models via get_managed_model() — a primary-key lookup on the internal UUID — but modelConfig.modelId is the Bedrock model_id that the runtime resolver, RBAC (permissions.models) and invocation all key on. A valid model would have been rejected 400 on save the moment the picker set one. Now matches by model_id, consistent with the whole chain. Frontend (Phase 4 UI): - New agents/ feature dir (separate from the Assistants editor): Agent + Binding + BindableItem TS contracts, a thin AgentApiService, and an AgentService signal facade with the accessible$ 404-probe idiom + a per-kind bindable cache. - Agents list page (model + binding-count badges) and an agent-form page: persona/emoji/tags/starters, a required single-select model picker (D3), tool/skill multi-select chips, and a memory-space picker with access (read/read+write — write disabled unless editor+ on the space, per D5) and an alwaysLoad MEMORY.md toggle. KB shown read-only. Sharing reuses the assistants share dialog (agentId == assistantId). - Routes agents / agents/new / agents/:id/edit, plus a sidenav "Agents" entry gated on the accessible$ probe. Tests: backend 1552 pass (9 new catalog + 4 new route + 3 updated model-validation); SPA build + tsc clean, ng test 7 AgentService + 11 sidenav specs pass. Co-Authored-By: Claude Opus 4.8 * test(sidenav): stub AgentService probe to fix unhandled HTTP rejection The sidenav constructor now probes agent accessibility (void agentService.loadAgents()), but sidenav.spec.ts didn't provide a mock AgentService, so the real service fired an unstubbed HTTP GET /agents that rejected with status 0 — vitest fails the run on unhandled errors even though all assertions passed. Provide a mock AgentService (accessible$ signal + no-op loadAgents) mirroring the schedule/memory stubs, plus parity tests for the probe + showAgents gate. Co-Authored-By: Claude Opus 4.8 * fix(agent-designer): align model write-check with the bindable catalog The catalog lists models via ModelAccessService.filter_accessible_models, but design-time write validation used can_access_model — and the two disagree. filter_accessible_models grants access whenever the model id is in the user's AppRole permissions.models; can_access_model only honors that membership when the model record ALSO carries a non-empty allowed_app_roles. So a model granted purely via the user's AppRole (empty allowed_app_roles) was listed by the picker but rejected on save with a 403 — and the runtime resolver (membership-based) would actually have allowed it. Validate the model with the same filter_accessible_models predicate the catalog uses, so 'if the palette offers it, the write accepts it' holds by construction. Adds a regression test for the empty-allowed_app_roles grant. Co-Authored-By: Claude Opus 4.8 * feat(sidenav): gate Memory Spaces + Agents to system-admin, add Preview badges Match the Scheduled Runs treatment for the two other preview surfaces: Memory Spaces and Agents now also require the system_admin AppRole (showX() && isAdmin()) in addition to their accessibility probe. Adds a small amber 'Preview' badge to all three nav entries (Agents, Memory Spaces, Scheduled Runs) so their preview status is visible. Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): resolve tool bindings at invocation (replace + per-invoker RBAC) An Agent's `tool` bindings were stored by the Designer but inert at run time — the free-select tool picker fully drove the toolset regardless of what the Agent bound. This resolves them, mirroring the shipped `modelConfig` override: - Run-time (inference-api): `resolve_agent_invocation` now returns `plan.tools` (`ResolvedTools`). When an Agent binds tools they *replace* the request's `enabled_tools` for the turn; each bound tool is re-checked against the INVOKING user via `AppRoleService.can_access_tool` (the same AppRole gate the harness uses for model, R2) and a missing tool blocks the turn with a message (D5). No tool binding ⇒ `plan.tools is None` ⇒ the request drives the toolset exactly as today. Wired at the existing `extra_tools`/`get_agent` seam via `effective_enabled_tools` (also feeds the spreadsheet/artifact tool gates + attachment guidance/inventory). - Design-time (app-api): `tool` dropped from `_INERT_KINDS`; a bound tool must be in the author's palette (`ToolCatalogService.get_user_accessible_tools`, the same source the picker fetches — "if the palette offers it, the write accepts it", cf. the model check). The palette is resolved once per write. `skill` bindings stay inert here (their run-time fold interacts with agent_type/skill resolution — a follow-up slice). Tests: 6 resolver cases (override, dedupe, block-on-missing, per-invoker, none→passthrough) + 5 validation cases (accessible/inaccessible/empty-ref/fetch-once/lazy). Full backend suite green (4621 passed). Co-Authored-By: Claude Opus 4.8 * feat(topnav): surface active assistant in the top nav Move the assistant/agent indicator out of the chat-input footer and into the top nav, beside the session title, so an attached assistant is visible throughout the conversation. - Add a compact 'variant' to app-assistant-indicator: a subtle name-only pill (emoji + name) that opens the same actions menu (New session / Edit / Share) on click. The full card style is preserved behind variant="card". - Add a menuPlacement input so the actions dropdown opens downward in the top nav instead of clipping off-screen. - Thread the assistant/owner/loading state and action outputs from the chat container into app-topnav; render the pill (with a loading shimmer) to the right of the title. - Remove the now-orphaned footer indicator and loading skeletons from the full-page chat container (embedded preview footer left intact). - Assistant card: move conversation starters into a collapsible accordion (expanded by default) to keep the card compact. Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): resolve skill bindings at invocation (replace + force skill-mode) Completes the tool/skill runtime-resolution gap (tools landed in #601). An Agent's `skill` bindings were stored by the Designer but inert at run time. This resolves them, mirroring the tool/model overrides: - Run-time (inference-api): `resolve_agent_invocation` now returns `plan.skills` (`ResolvedSkills`). When an Agent binds skills they *replace* the request's skills for the turn AND the route forces `agent_type="skill"` so the SkillAgent discloses exactly the bound set. Each bound skill is re-checked against the INVOKING user via `AppRoleService.can_access_skill`; a missing skill — or the Skills feature being disabled in this environment — blocks the turn with a message (D5). No skill binding ⇒ `plan.skills is None` ⇒ the request's agent_type/enabled_skills drive the turn as today. Wired by reassigning `effective_agent_type`/`effective_skill_ids` before the main-turn get_agent, so the values flow into the construction snapshot and a bound-skill agent resumes on the same skills_hash (resume-safe, same mechanism the tool slice relies on). - Design-time (app-api): `skill` dropped from inert (no inert kinds remain). A bound skill is flag-gated (`skills_enabled()`) and must be in the author's palette (`resolve_accessible_skill_ids`, the same source the picker fetches — cf. the tool check); the palette is resolved once per write and only when skills are enabled. Tests: +6 resolver (override, dedupe, flag-off block, block-on-missing, per-invoker, none →passthrough) + 6 validation (accessible/inaccessible/empty-ref/flag-off/fetch-once/lazy). Full backend suite green (4631 passed). Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): reflect governed agent bindings in the chat-input (lock pickers) The backend governs an Agent's model/tool/skill bindings at invocation (#601, #602) — the agent's set wins regardless of what the client sends. The chat-input still showed the model/tool/skill pickers as free-select, which was dishonest (a change the backend ignores). This locks each picker to the active Agent's bindings, per primitive. - Session page (`session.page.ts`): inject AgentService/ToolService/SkillService; fetch the governed Agent alongside the assistant (agentId == assistantId) in `loadAssistant`; apply per-primitive locks from `modelConfig`/`bindings`, and release them when navigating to plain chat. Best-effort: the /agents surface may be disabled (404) or the assistant may be a legacy assistant with no bindings — every failure leaves the pickers free-select. - ModelService/ToolService/SkillService: add a small agent-lock API (`lockToAgent*` / `clearAgentLock` + `agentLocked`/`agentModelLocked`). While locked, `enabledToolIds`/ `enabledSkillIds` return the bound set (replace semantics, matching the backend), toggles no-op, and `isToolShownEnabled`/`isSkillShownEnabled` render the bound set honestly. - UI: model-dropdown shows a locked read-only chip ("set by this agent"); model-settings shows a "Set by agent" model row and a "This agent uses a fixed set of tools/skills" banner, with tool/skill/sub-tool toggles disabled + greyed while locked. This is UI honesty, not enforcement — the backend remains the authority. Per-primitive: an agent that binds a model but no tools locks only the model; the rest stay free-select. Tests: +5 tool-lock, +5 skill-lock, +4 model-lock service specs (ng test, 51 pass); `tsc` clean; production build (AOT template check) clean. Known limitations (documented for follow-up): a model race if the pinned model isn't in the user's loaded set yet (dropdown disables but may show the fallback name until models load); the skill-lock banner only shows in skills chat-mode. Co-Authored-By: Claude Opus 4.8 * fix(agent-designer): release chat-input picker locks on new conversation The agent-binding picker locks live in root singleton services (Model/Tool/Skill Service) that outlive the session component. Clicking "New chat" navigates to `/`, which recreates the session component with fresh assistant()/agent() signals (both null). The lock-release lived inside the `if (loadedAssistant || … || agent())` guard, which is false on that fresh component — so the stale locks from the previous agent conversation were never released, leaving the model + tools pickers stuck. Move `clearAgentBindingLocks()` out of the guard so it always runs when there is no assistant in the URL. Idempotent — a no-op when nothing is locked. Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): show only the bound tools/skills when an agent locks the settings When an Agent dictates a fixed toolset/skillset, the settings panel listed every accessible tool/skill with the bound ones toggled on and the rest greyed off — a long, noisy list. Filter to show ONLY the bound (enabled) tools/skills so the panel reflects exactly what the agent uses. - ToolService.visibleTools / SkillService.visibleSkills: agent-locked → filter to the bound ids; otherwise the full accessible list. - model-settings template iterates the visible* lists. Tests: +1 tool-lock, +1 skill-lock spec (ng test green); tsc + AOT build clean. Co-Authored-By: Claude Opus 4.8 * chore(agent-designer): default AGENTS_API_ENABLED on with a kill switch The Agent Designer is complete (contract → surface → resolution → Designer UI → binding reflection), so flip the feature flag from opt-in to default-on, matching the house style for shipped features (scheduled_runs / memorySpaces). - backend `agents_enabled()`: empty-string-safe default-on — unset/empty ⇒ enabled, only the literal "false" disables (was `== "true"`, default off). - CDK `config.agents.enabled`: mirror the memorySpaces/scheduledRuns ternary (`!== 'false'` + context fallback `?? true`), so an unset/empty GitHub Actions var can't silently disable it. - Tests: add the Agents API default-on/empty/kill-switch/context suite to config.test.ts (mirrors Memory Spaces); rename the app-api-environment threading test (no longer "default off"). The `/agents/*` API now ships everywhere; the SPA nav stays preview-gated (system-admin + "Preview" badge) until Assistants are deprecated, so this doesn't broaden user-facing exposure — it just stops the API 404ing per-environment. Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): manage an agent's knowledge base from the Agent Designer Extract the assistant editor's inline "Knowledge base" section into a standalone, reusable KnowledgeBaseSectionComponent and use it in both the assistant form and the Agent Designer — replacing the agent form's read-only "managed automatically" card with the live document/web-crawl/connector flow. This closes the last Agent migration blocker. The gap was frontend-only: the document upload/ingestion/retrieval pipeline already keys on the record id and agentId == assistantId, so /assistants/{id}/documents backs an agent unchanged. No backend or data-model changes (Option 1, not the deferred F4 first-class KB primitive). The component owns record identity via a createDraft callback so the first content-adding action can mint a draft in create mode; a permissionResolved input gates the edit-only sync-policy calls so a viewer never 403s on the default owner guess. The assistant form keeps createDraftAssistant as its callback (shedding ~1000 lines); the agent form adds createDraftAgent and drops the read-only kbBinding path. Verified: ng build clean, ng test 1449 specs green (incl. assistant-form spec). Co-Authored-By: Claude Opus 4.8 * test(scheduled-runs): freeze dispatcher clock to de-flake cadence rearm test test_next_run_at_uses_schedule_cadence asserted the daily-9am re-arm delta fell in (1h, 48h), which fails when CI runs in the hour before 9am Boise (the next daily run is legitimately <1h away). Freeze dispatcher._now to a fixed instant and assert next_run_at equals compute_next_run_at recomputed from the same instant, making the test time-of-day independent. Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): govern model params + live editor preview Model-params governance: - binding_validation._validate_model_params rejects params that are unsupported / locked / out-of-[min,max] / out-of-allowed against the model's admin supported_params (belt-and-suspenders to the runtime merge; author-facing 400 instead of a silent clamp). +9 tests. - Data-driven Parameters subsection under the model picker reading meta.supportedParams (numeric inputs, enum selects, locked read-only); empty params omit `params` (today's exact resolution). Live side-by-side preview in the agent editor: - New AgentPreviewComponent reuses PreviewChatService and streams the SAVED agent through the real /chat/stream invocation path, so all bindings (model/params/tools/skills/memory) resolve server-side. Capability strip + dirty banner make the resolved context and the save-to-apply semantics explicit. - Agents send a minimal request body (message/session_id/agent id) and opt out of the assistant preview's system_prompt + owner-tools injection, which fought the bindings and blew the 8KB system_prompt cap for long personas (422). PreviewChatService gains a backward- compatible opts flag; assistant preview behavior unchanged. - Two-column editor shell mirroring the assistant editor. Verified: backend 59 pass (9 new), ng build clean, 16 SPA specs (7 agents + 9 preview-chat). Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): lock preview model picker; trim preview nav The Agent Designer preview reused the main chat-input, whose model dropdown reads the root ModelService — so it showed the user's global model (e.g. Sonnet 5) and let them switch it, even though the harness resolves the model from the agent's binding server-side. Wire the preview to lock that picker to the agent's model via the same lockToAgentModel mechanism the session page uses for a real agent conversation, released on destroy (and idempotently on the next plain chat via the session page's self-heal effect). Also hide the Memory Spaces and Scheduled Runs side-nav entries for now (routes/pages and their capability probes are unchanged, so re-enabling is just re-adding the template blocks). Agents stays system-admin only. Co-Authored-By: Claude Opus 4.8 * fix(memory-spaces): route namespaced entry slugs via :path converter Entry slugs are namespaced with a slash (e.g. `people/brian-bolt`), but the app-api entry routes declared a plain `{slug}` param whose converter stops at `/`. Uvicorn percent-decodes `%2F`→`/` before routing, so `/entries/people/ brian-bolt` never matched `/entries/{slug}` and returned 404 on view/edit/delete. Switch the GET/PUT/DELETE entry routes to the `{slug:path}` converter so the embedded slash is captured and the slug arrives matching the manifest. Adds a route test exercising upsert→read→delete with a slashed slug. Co-Authored-By: Claude Opus 4.8 * feat(memory-spaces): let the agent read/write MEMORY.md via reserved slug MEMORY.md is the space's human-readable index — a standalone S3 object outside the entries manifest, injected into the agent's context each session via hydration. It never appears in `memory_list`, and the agent had no tool to read it back or keep it in sync with the entries it writes, so the machine-readable manifest and the human-readable index could silently drift. Route the reserved `"MEMORY.md"` slug (case-insensitive) through the existing service methods: `memory_read("MEMORY.md")` → `read_index` (viewer+), `memory_write("MEMORY.md", body)` → `update_index` (editor+, body only). No new tool surface; matches the literal hydration already uses. The slug is reserved — the agent cannot create an ordinary entry named MEMORY.md. Write stays gated identically to entry writes (only bound when the binding grants readwrite; service re-checks editor+). Docstrings + spec §4/§5 updated. Co-Authored-By: Claude Opus 4.8 * feat(schedules): target Agents instead of Assistants on scheduled runs The scheduled-run form's target selector now lists Agents (the Agent Designer primitive that supersedes the Assistant) instead of Assistants. Same underlying record — agentId == assistantId — so the wire field stays `assistantId` and no backend change is needed for the swap. Because an Agent's `tool` bindings replace the run's `enabled_tools` at invocation (agent_binding_resolver / routes.py effective_enabled_tools), the manual tool picker is now hidden whenever an Agent is selected — showing it would let the user pick tools that get silently discarded. The picker (and its snapshot semantics) remains only for the "Default agent" case. Submit drops any stale snapshot when an Agent is targeted. Also fixes "Run now" to target the selected Agent via ragAssistantId (the /runs/now backend already accepts it) — previously it ignored the target, so the attended test surface didn't match what the schedule would actually run. Co-Authored-By: Claude Opus 4.8 * chore(kaizen): weekly research scan 2026-07-10 Generated by the kaizen-research skill. Top 5 ideas appended to docs/kaizen/review-queue.md for the kaizen-review-prep run later this morning. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(deps): upgrade Strands to 1.47.0 and add aws-bedrock-token-generator Bumps strands-agents 1.40.0 -> 1.47.0 (and the [bidi] extra to match) and adds aws-bedrock-token-generator==1.1.0 (bounded >=1.1.0,<2.0.0 by strands' openai extra). strands-agents-tools stays at 0.5.2 (resolver-confirmed compatible). Unblocks Bedrock Mantle work that needs the newer SDK: - OpenAIResponsesModel (Responses API) for models that don't support Chat Completions (e.g. openai.gpt-5.x on Mantle). - bedrock_mantle_config, which mints the Mantle bearer token via aws-bedrock-token-generator and derives the base URL + model-family base path (openai.gpt-5.* -> /openai/v1, else -> /v1). Full backend suite green on 1.47.0 (2306 passed). Co-Authored-By: Claude Opus 4.8 * fix(scheduled-runs): remove RBAC gate causing prod 403 "Access Denied" Regular users hit a 403 "You do not have access to scheduled runs" toast on page load. The `/schedules` and `/runs/*` surfaces were gated by the `scheduled-runs` RBAC capability, granted only to a beta cohort's AppRole (admins passed via the `*` wildcard). The sidenav ran a background `loadSchedules()` probe on every load, and the global errorInterceptor popped the toast on the 403 before the schedule service's graceful catch ran. The feature doesn't need admin/beta gating — keep it low-key and reachable only by direct URL for now: - Drop the capability check from both `require_scheduled_runs_user` gates; only the `SCHEDULED_RUNS_ENABLED` kill switch remains (404 when off). Runs still execute with the caller's own RBAC-allowed tools, so this widens who can reach the surface, not what any one caller can do. - Remove the vestigial sidenav schedules probe and dead showSchedules/navigateToSchedules wiring (the template never rendered a "Scheduled runs" link). - Update route + sidenav tests accordingly. `apis/shared/rbac/capabilities.py` is now unreferenced; left in place as generic RBAC infra so re-gating is a two-line revert. Co-Authored-By: Claude Opus 4.8 * docs: consolidate release workflow into one auto-invoked steering doc + skill Fold the versioning and release-notes guidance into a single 'cutting a release' guide covering the branch workflow, SemVer bump + version sync, change identification across the divergent main/develop histories, writing both release docs, the squash-merge PR into main, and the required backmerge into develop. - Add .kiro/steering/cutting-a-release.md (inclusion: auto — name + description, intent-triggered) - Add .claude/skills/cutting-a-release/ (SKILL.md auto-invoked via description) with references/{release-notes-format,changelog-format}.md for progressive disclosure - Remove superseded .kiro/steering/{versioning,release-notes}.md and .claude/skills/{versioning,release-notes}/ - Repoint .github/copilot-instructions.md at the consolidated skill/steering * feat(models): Mantle Responses API + per-model region; drop endpoint-path knob Refactors the admin "mantle" provider onto Strands' bedrock_mantle_config so the SDK owns the base URL, model-family base path, and bearer-token minting — removing hand-rolled inference plumbing. Adds the two things the library can't infer as declarative per-model fields: - apiMode (chat | responses): selects OpenAIModel vs OpenAIResponsesModel. Some Mantle models (e.g. openai.gpt-5.x) only serve the Responses API and reject Chat Completions, which the endpoint-path knob could never satisfy. - region: optional override into bedrock_mantle_config["region"], driving both the Mantle endpoint host and the SigV4 region the token is signed for — so a model can pin inference to its host region (e.g. gpt-5.x in us-east-1) independent of where the app runs. mantleEndpointPath is kept as an accept-but-ignore deprecated schema field (no stored record breaks) and removed from the UI + runtime. The Responses API uses different native param names, so to_mantle_config selects a Responses map (max_output_tokens, nested reasoning.effort) by mode. Runtime fields (mantle_api_mode/mantle_region) thread through model_config, the agent factory, base_agent, the paused-turn snapshot, stream_coordinator, and the chat service/routes. get_mantle_base_url/generate_bedrock_bearer_token are retained for the admin model-browse list (not inference). Gemma 4 (google.gemma-4-31b) is temporarily un-curated: it needs the /openai/v1 base path but the SDK only routes openai.gpt-5.* there, and bedrock_mantle_config forbids a base_url override. Re-add once the "google.gemma-" family prefix lands upstream in strands-agents/sdk-python. Backend suite green (2342). Frontend typecheck + manage-models specs green. Co-Authored-By: Claude Opus 4.8 * fix(api-converse): serve /chat/api-converse from app-api, not via inference proxy The API-key converse endpoint was broken in cloud. app-api proxied POST /chat/api-converse to `{INFERENCE_API_URL}/chat/api-converse`, but inference-api now runs inside an AgentCore Runtime whose data plane only serves POST /invocations and GET /ping — any other path returns UnknownOperationException (404) before reaching the container. It worked locally only because localhost:8001 bypasses the runtime gateway. Relocate the handler onto app-api as a self-contained route (validate key -> RBAC -> bedrock-runtime.converse -> cost accounting), reusing the shared services it already depends on. app-api reaches Bedrock directly via its task role, so there is no inference-api hop and no INFERENCE_API_URL dependency. Delete the proxy, the now-dead inference-api route, and its DTOs (moved to app_api/chat/models.py). Repoint the converse tests at the app-api module. Verified: 263 backend tests pass, import-boundary test clean, and a real un-mocked smoke against a Bedrock model returns 200 (stream + non-stream). Co-Authored-By: Claude Opus 4.8 * fix(app-api): grant Bedrock streaming + inference-profile invoke The relocated /chat/api-converse handler calls Bedrock Converse from app-api, so the task role's invoke grant must cover what the catalog's model IDs need. Expand the BedrockInvokeModel statement to add bedrock:InvokeModelWithResponseStream (the stream=true path) and broaden resources to all-region foundation models plus the account-level inference-profile ARN, since the catalog uses `us.*` cross-region inference profiles. Mirrors inference-api's BedrockModelInvocation grant. Verified: infra tsc clean, 442 infra jest tests pass. Co-Authored-By: Claude Opus 4.8 * fix(settings): point API-key snippets at /api/chat/api-converse After the BFF refactor, CloudFront only routes /api/* to the backend; other paths hit the SPA origin, which rejects POST with a CloudFront 403. The generated curl/Python/JS examples emitted the bare origin, producing `/chat/api-converse`. Resolve a relative/empty appApiUrl against the current origin so snippets target `/api/chat/api-converse`; leave an already-absolute value (local dev's http://localhost:8000) untouched. Co-Authored-By: Claude Opus 4.8 * feat(api-converse): route Bedrock Mantle models via a shared builder The API-key /chat/api-converse handler was Bedrock-only; provider="mantle" models (e.g. openai.gpt-5.4) 400'd because it always called bedrock-runtime.converse. Add a Mantle path so the full model catalog works. Extract the Mantle model construction (class-pick + bedrock_mantle_config) and its param maps + MantleApiMode enum out of agents/main_agent/core into a new apis/shared/models/mantle.py, so the agent factory and the API-key handler share ONE implementation (app-api can't import agents/). The factory now delegates to build_mantle_model. The handler resolves the requested model's provider from the catalog and branches: bedrock -> boto3 converse (unchanged); mantle -> the shared builder + the bare Strands model's .stream(), which yields the same Converse-shaped events the Bedrock path already emits — so SSE translation and usage/cost accounting are shared (cost is now tagged with the real provider). Unknown / lookup-failure ids fail safe to the Bedrock path. Verified: shared builder + factory-delegation + handler mantle-path unit tests; and real dev-ai smokes — chat-mode Mantle and Responses-API Mantle (openai.gpt-5.4) both return 200 (stream + non-stream) against the live endpoint. Co-Authored-By: Claude Opus 4.8 * feat(app-api): grant bedrock-mantle:CreateInference for api-converse The api-converse Mantle path invokes a Mantle model directly from app-api, so the task role needs bedrock-mantle:CreateInference (Mantle's own IAM namespace) — without it, mantle requests AccessDeny. Fold it into the existing project-scoped Mantle statement (was browse-only Get*/List*), renamed BedrockMantleInference to mirror the runtime role's grant. Verified: infra tsc clean, integration jest green (24 passed). Co-Authored-By: Claude Opus 4.8 * feat(identity): MCP user identity forwarding via access-token enrichment Add an opt-in Cognito Pre-Token-Generation v2 Lambda that copies configured user-pool attributes into namespaced claims on the ACCESS token, so personalized MCP tools can identify the caller. The access token is the only token forwarded end-to-end to MCP servers, so enrichment needs no changes to the SPA -> app-api -> inference-api -> MCP forwarding path. Shipped disabled by default (opt-in): a fork that configures nothing gets zero resources and the token is forwarded as before. Enabling requires the Cognito Essentials feature plan (pinned on the pool) plus two GitHub Actions variables (CDK_MCP_TOKEN_ENRICHMENT_ENABLED + CDK_MCP_TOKEN_ENRICHMENT_CLAIMS), keeping the committed cdk.context.json inert. - config: McpIdentityConfig (enabled + accessTokenClaims); claim map settable via JSON env var or context; parseJsonRecordEnv helper. - handler: stdlib-only, fail-open Pre-Token-Gen v2 trigger (returns event unchanged on any error so login is never blocked). - construct: real-code Lambda (fromAsset) attached via addTrigger V2_0; pool featurePlan pinned to ESSENTIALS. - wired conditionally into PlatformStack; platform.yml job-level env. - docs: spec updated (open questions resolved) + implementation summary, incl. the mcp-servers follow-on handoff. Ref: docs/specs/MCP_USER_IDENTITY_FORWARDING_SPEC.md * fix(app-api): grant bedrock-agentcore:CreateTokenVault for OAuth provider create Admin "add OAuth provider" (POST /admin/oauth-providers/) returned a 502 Bad Gateway. dev-ai app-api logs showed the real cause: an AccessDeniedException on bedrock-agentcore:CreateTokenVault against token-vault/default. AgentCore's CreateOauth2CredentialProvider ensures the default token vault exists on the first provider create, which requires CreateTokenVault (+ GetTokenVault) on the caller. The app-api task role had the ...Oauth2CredentialProvider actions but not the TokenVault ones. The shared error handler maps an uncaught AWS ClientError to HTTP 502, so the missing permission surfaced as a 502 rather than a 403. Add CreateTokenVault + GetTokenVault to the AgentCoreWorkloadIdentityAccess statement. The resource scope (token-vault/*) already covered token-vault/default; only the actions were missing. Requires a platform.yml (CDK) redeploy to take effect. Co-Authored-By: Claude Opus 4.8 * fix(scripts): make sync-version.sh portable across GNU and BSD tools The version-sync script only ran inside the dev container / CI (GNU coreutils); on macOS (BSD sed/grep) it errored out and silently left the manifests un-synced, so a release cut locally had to hand-edit every manifest. Replace the three GNU-only constructs with POSIX equivalents: - `grep -oP ... \K` (Perl regex) -> `sed -n 's/.../\1/p'` / awk field split - `sed -i "expr"` (GNU in-place) -> sed_inplace helper (temp file + mv) - `sed "0,/re/s/..."` (GNU-only address) -> awk first-match replace Behavior is unchanged on GNU; the script now runs identically on macOS. Verified both --check and the write path (incl. shields.io `--` hyphen doubling and SemVer->PEP 440 lock conversion) round-trip on BSD tools. Co-Authored-By: Claude Opus 4.8 * feat(admin): make admin sidebar nav sticky on desktop Pin the admin layout aside below the sticky top bar so the section nav stays in view while the content area scrolls. Uses lg:self-start so the aside shrinks to its content (flex items stretch to full height by default, which defeats position:sticky), plus a max-height + overflow so a long nav scrolls internally. Mobile dropdown is untouched. Co-Authored-By: Claude Opus 4.8 * feat(frontend): redesign 404 page to match auth screens Rework the not-found page onto the same design system as the login and first-boot pages: the primary-derived lava-lamp parallax backdrop (six depth-tiered morphing blobs), the masked graph-paper grid overlay, and the frosted-glass card. The oversized 404 sits above the card where the auth pages place the logo, so all three screens read as one system. Preserves existing behavior (sidenav hide/show, Return Home, Go Back) and respects prefers-reduced-motion. Classes are nf-prefixed and component-scoped via view encapsulation. Co-Authored-By: Claude Opus 4.8 * fix(frontend): make shell scroll container real so sticky nav engages The admin aside's lg:sticky never engaged because its nearest scrolling ancestor was the app shell's `flex-1 overflow-y-auto` div, which had no bounded height — it grew to content and the window scrolled instead, so sticky bound to a box that never moved. Pin
to h-dvh so that div becomes a genuine scroll container; the admin aside and top bar now stick. Also apply the admin bar's frosted-glass treatment (bg-*/opacity + backdrop-blur-sm) to the session topnav so the two surfaces match. Co-Authored-By: Claude Opus 4.8 * docs(specs): quota cooldown windows + platform ceiling spec and committee one-pager Replaces the hard monthly quota cutoff with a three-layer model: anchored 5-hour cooldown windows (Claude-style, exact reset times), a hard admin-adjustable platform-wide monthly ceiling as the fiscal guarantee, and the per-user monthly limit demoted to a generous anti-runaway backstop with degrade-to-economy-model as the target behavior. Backstop horizon (monthly vs weekly) is a per-tier choice. Includes an admin pilot tuning playbook with an observe-only phase, a user-facing quota status endpoint, recommended opening numbers, and a 7-PR implementation breakdown. The one-pager is the committee-facing rationale. Co-Authored-By: Claude Fable 5 * fix(frontend): guarantee JIT compiler in vitest runs to stop PlatformLocation flake The unit-test builder keeps Angular packages external, so vitest evaluates raw fesm2022 chunks whose partial declarations (ɵɵngDeclareInjectable/ ɵɵngDeclareFactory) compile eagerly and require @angular/compiler. Its presence was incidental — loaded transitively via @angular/core/testing in the builder's init-testbed setup — so specs with no static Angular imports (app.spec.ts dynamic-imports './app') could evaluate an unlinked @angular/common chunk first and fail with "The injectable 'PlatformLocation' needs to be compiled using the JIT compiler, but '@angular/compiler' is not available" (angular/angular-cli#31993). - add src/test-setup.ts importing @angular/compiler, wired via the test target's setupFiles and included in tsconfig.spec.json - add src/test-setup.spec.ts guarding the invariant deterministically - bump the first shared-view.page spec to 15s: it pays the one-time dynamic page-chunk import, which can exceed 5s under full-suite load Co-Authored-By: Claude Fable 5 * fix(frontend): size chat scroll space to the response, adapt to shell scroll container Replace the fixed viewport-tall bottom spacer in the message list with a min-height on the last turn group (user message + its assistant responses). The response streams into the reserved space instead of pushing a static spacer further down: a short response leaves exactly the room needed to pin the user message at the top, and a response taller than the viewport leaves zero dead scroll below it. Turn groups are keyed by their first message id so a finished turn's DOM (including live MCP App iframes) never remounts when the next turn starts, and the end-of-conversation sections (loader, consent/approval prompts, compaction, orphan artifacts) render inside the reserved space so they stay visible next to the response. Also adapt the session page to the real shell scroll container introduced by #634 (frosted sticky nav): the window no longer scrolls, which had silently broken submit scroll-to-message and scroll save/restore. scrollToMessage now uses scrollIntoView with a scroll-mt-20 header offset, and save/restore reads the shell container's scrollTop via a stable #app-scroll-container hook. Co-Authored-By: Claude Fable 5 * feat(settings): make user settings sidebar nav sticky on desktop Mirror the admin layout change (#632): pin the settings aside below the sticky top bar so the section nav stays in view while the content area scrolls. Uses lg:self-start so the aside shrinks to its content (grid items stretch to full row height by default, which defeats position:sticky), plus a max-height + overflow so a long nav scrolls internally. Mobile dropdown is untouched. Co-Authored-By: Claude Opus 4.8 * feat(admin-tools): discover OAuth-gated MCP servers with the admin's vaulted token The admin tool "Discover" flow refused OAuth-gated MCP servers outright, so servers like the GitHub remote MCP server (api.githubcopilot.com/mcp/) could not be discovered — discovery either 400'd on auth_type=oauth2 or connected unauthenticated and got a 401 from the server (wrapped to a 400). Discovery now accepts the OAuth provider id and connects using the admin's own vaulted 3LO token for that provider, fetched via AgentCore Identity (get_token_for_user) and injected as a bearer — mirroring how the agent loop attaches the end-user's provider token at runtime, and reusing the exact path connector_status already uses. This validates the admin's own connection and lists the tools their token can see (providers such as GitHub scope-filter the tool list to the token's grants). It fetches the admin's token only; it cannot mint an arbitrary end-user's token. Backend: - Add requires_oauth_provider (alias requiresOauthProvider) to MCPDiscoverRequest. - Handler loads the provider, fetches the admin's vaulted token, injects it as oauth_token into create_external_mcp_client. requires_consent -> 409, unknown provider / conflict with forward_auth / oauth2-without-provider -> 400. Frontend: - Send requiresOauthProvider in the discover payload (the form control already existed) and the OAuth2CallbackUrl header (bare /oauth-complete, no query string) so the backend can resolve the admin's token. Tests: 5 backend tests for the OAuth-provider discovery path; 2 SPA specs for the discover payload. Co-Authored-By: Claude Opus 4.8 * feat(manage-models): add Sonnet 5 + GPT-5.4 curated cards, order by capability Add two curated model catalog cards: - Claude Sonnet 5 (bedrock, global.anthropic.claude-sonnet-5) — 1M context, effort-based reasoning, caching on. - GPT-5.4 (mantle, openai.gpt-5.4) — Responses API surface; the openai.gpt-5.* model id matches the SDK's /openai/v1 routing prefixes, so one-click create routes correctly (unlike the commented-out Gemma card). Order the Bedrock Claude cards most-capable-first (Opus 4.7, Sonnet 5, Sonnet 4.6, Haiku 4.5) and place GPT-5.4 ahead of Qwen in the Mantle list. Move the "Bedrock Mantle" provider tab next to "Bedrock" in the catalog selector. Co-Authored-By: Claude Opus 4.8 * fix(mantle): route google.gemma-4-* to /openai/v1 base path Gemma 4 is served ONLY on Mantle's /openai/v1 path (per its AWS model card), but the Strands SDK's _OPENAI_PATH_MODEL_PREFIXES ships only "openai.gpt-5.", so google.gemma-4-* fell through to /v1 and inference 401'd with access_denied ("... is not enabled for this account"). Append "google.gemma-4-" to the SDK's prefix table at build time (_ensure_gemma4_openai_v1_routing: lazy, idempotent, guarded) until it lands upstream. Scoped to the 4.x family — Gemma 3 stays on /v1. - Guard tests: prefix registers on build, all three Gemma 4 variants resolve to /openai/v1, Gemma 3 stays on /v1, registration idempotent. - Correct the stale curated-models.ts note (the "would fail at chat time" claim is obsolete; its "google.gemma-" re-add hint would have misrouted Gemma 3). - Add design note proposing mantleEndpointPath as a live admin setting as the durable alternative to chasing the SDK's hardcoded table. Co-Authored-By: Claude Opus 4.8 * feat(manage-models): make max output tokens optional Newer reasoning / Responses-API models (GPT-5.x, Claude with adaptive thinking) don't publish a discrete max-output-tokens value — output shares the context budget with reasoning tokens, so there's no fixed cap to enter. Our own GPT-5.4 curated card already carries a decorative value with no backing max_tokens spec. maxOutputTokens is only a ceiling for the admin-configured max_tokens inference param and is never sent to the provider, so leaving it unset is safe at inference time. This makes the admin form field optional to match. - ManagedModelCreate / ManagedModel: max_output_tokens -> Optional[int] - DynamoDB write: omit maxOutputTokens when absent (matches other optionals) - Form control: drop Validators.required, default null (number | null); the 0-default + min(1) combo would otherwise still block submit - SPA interfaces typed number | null; catalog card null-guarded (shows "— out") - Both ceiling validators already skipped an absent value — no change needed Co-Authored-By: Claude Opus 4.8 * fix(docker): float curl security patch to survive Debian mirror purges Debian removes the superseded point version of curl from the trixie mirror on each security update, so an exact +deb13uN pin breaks every build once the next CVE lands. Pin to +deb13u* to track the live patch while keeping the minor version fixed; the digest-pinned base image is what actually provides reproducibility. Co-Authored-By: Claude Opus 4.8 * feat(web-sources): allow removing a web source A web source could be added but never removed. There was no DELETE route, no client method, and no UI affordance — the only way to drop one was to delete every page document it produced and let the orphan cascade in cleanup_service pick up the crawl row as a side effect. Add the operation as a first-class one, inverting that existing cascade: DELETE /assistants/{id}/web-sources/crawls/{crawl_id} removes the crawl's sync policy, soft-deletes every page under its root URL (vectors and S3 teardown hand off to the same background cleanup the single-document path uses), then hard-deletes the crawl row. A crawl that is genuinely in flight is refused with a 409 rather than raced — the crawler would keep writing pages we just enumerated. A crawl stuck at 'running' because its process died is not in flight and stays deletable, so a zombie source can't become permanently undeletable. The route is edit-gated (owner or editor), matching the documents surface that renders the list. Co-Authored-By: Claude Opus 4.8 * fix(web-sources): let editors start and view crawls start_crawl, list_crawls and get_crawl gated on the owner-keyed get_assistant(), which returns None for a user holding only an editor share — so an editor got a 404 from the "Add web content" button the SPA already renders for them (canManageSync() shows it to anyone who isn't a viewer). Route them through the same _require_edit_permission helper the documents, sync-policies and delete-crawl surfaces use, so owner|editor is the gate and a viewer gets a 403 instead of a misleading 404. No owner_id threading is needed on these three: the document writes are keyed on the assistant (PK=AST#), not its owner, and imported_by_user_id/started_by_user_id intentionally record the *acting* user — substituting the owner there would credit an editor's import to the owner. owner_id stays confined to the delete path, whose soft_delete_document/_list_crawl_pages calls really are owner-keyed. Co-Authored-By: Claude Opus 4.8 * fix(rbac): make AppRole the single source of truth for model access The model admin page and the role admin page wrote to two different, unlinked fields. Enabling a model for a role on the model page wrote `allowedAppRoles` onto the model record — a field no access check ever read — so the grant silently did nothing: the role page still showed the model unchecked, and users never saw it in the chat picker. Only editing the role's `grantedModels` had any effect. Make the role record the single source of truth, matching the pattern tools and skills already use: - The model form's role picker now writes THROUGH to each selected role's `grantedModels` (new ModelRoleService.set_roles_for_model), mirroring set_roles_for_tool. Create/update/delete routes wire it up, migrating grants on a modelId rename and revoking them on delete. - `allowedAppRoles` is no longer persisted on the model; it is derived from the role records on read (hydrate_model_roles), so the model page and role page can no longer disagree. Adds `inheritedAppRoles` for wildcard/inherited grants, surfaced read-only in the form. - can_access_model and filter_accessible_models both delegate to one `_grants_access` predicate. They previously diverged (one gated on allowed_app_roles, one didn't), so a model could be listed by the catalog yet denied on use. - Removes the dead POST /sync-roles endpoint (never called; only existed to paper over the drift); replaces it with GET /managed-models/{id}/roles. - Drops Validators.required on the picker, since a model reachable only via a wildcard grant legitimately has zero direct grants. Adds regression coverage for the write-through, the derived read, and the two access checks agreeing. Full backend + frontend suites green (the 8 pre-existing get_metadata_storage failures are unrelated). Co-Authored-By: Claude Opus 4.8 * fix(tests): repoint storage patch target and harden integration gate Two pre-existing failures on develop, both unrelated to the code under test: - test_cache_savings.py patched apis.app_api.storage.get_metadata_storage, but that accessor moved to apis.shared.storage (the app_api.storage module is now an empty stub). Repoint all 5 patch targets. Production code in sessions/services/metadata.py already imports from the new location. - test_compaction_integration.py gated its real-AWS integration tests on AGENTCORE_MEMORY_ID. That variable leaks into the process mid-suite when other tests reload apis.app_api.main (load_dotenv(override=True) injects a local backend/src/.env), so the tests ran order-dependently against invalid credentials instead of skipping. Gate on an explicit RUN_AGENTCORE_INTEGRATION_TESTS=1 opt-in instead. Full suite: 4771 passed, 6 skipped. Co-Authored-By: Claude Opus 4.8 * fix(chat): keep SSE stream open across tab switches @microsoft/fetch-event-source defaults to openWhenHidden:false, which aborts the SSE connection on visibilitychange-to-hidden and reopens it — issuing a fresh POST /invocations for the SAME turn — when the tab becomes visible again. That reopen happens inside the library, reusing the request and bypassing the SPA's per-session double-submit and streamId supersession guards. Because a client abort does not propagate through the AgentCore Runtime data plane, the original backend agent keeps running while the reopened one runs the same turn concurrently. Both persist tool-use/tool-result events to the same AgentCore Memory session, corrupting history with duplicate / interleaved toolResult turns and bricking the conversation with a Bedrock "toolResult blocks exceed toolUse blocks" ValidationException. Set openWhenHidden:true on both fetchEventSource call sites so a single stream stays alive across tab switches (also correct for long agentic turns). The server-side restore-time repair is the safety net for already -corrupted histories. Co-Authored-By: Claude Opus 4.8 * fix(sessions): repair tool-use/tool-result pairing on restore Bedrock Converse rejects any history where a user turn's toolResult blocks do not exactly match the preceding assistant turn's toolUse blocks ("The number of toolResult blocks at messages.N exceeds the number of toolUse blocks of previous turn"). A single such violation anywhere in a session's persisted history makes every subsequent turn fail, permanently bricking the conversation. Such corruption can be written by concurrent/interrupted turns with parallel tool calls (e.g. a duplicate invocation spawned by a tab switch): duplicate toolResult turns, toolResults reordered away from their toolUse turn (assistant/assistant/user/user), or toolResults orphaned after a synthetic error turn. The SDK's own _fix_broken_tool_use only rebuilds the single message after each toolUse turn, so it does not repair these shapes. Add TurnBasedSessionManager._repair_tool_pairing, an unconditional restore-time normalizer (sibling to _strip_document_bytes) that rebuilds a Bedrock-valid history on the final agent.messages: every toolUse turn is immediately followed by exactly one matching result turn (missing ones synthesized as errors), duplicate/orphaned result turns are dropped, and consecutive same-role turns are merged. No-op (identity) on healthy history. Kill switch: AGENTCORE_MEMORY_HISTORY_REPAIR_ENABLED=false. Validated against a real bricked production history (24 violations -> 0, idempotent). Self-heals affected sessions on their next turn. Co-Authored-By: Claude Opus 4.8 * test(sessions): make compaction fixtures valid Converse histories Two pre-existing compaction tests fed the session manager a user toolResult turn with no preceding assistant toolUse (make_tool_result_message alone) — an invalid Converse history that Bedrock would also reject. The new restore-time _repair_tool_pairing correctly drops/merges those orphaned turns, changing the message counts the tests asserted. Give each fixture a matching toolUse turn before the toolResult so the repair no-ops and the tests exercise compaction/truncation and checkpoint slicing in isolation. Counts updated accordingly (4->5 kept; slice 2->3). Co-Authored-By: Claude Opus 4.8 * fix(sessions): guard synthetic error persistence against role-alternation breaks When a turn errors inside the agent stream, the handler persists a synthetic "⚠️ Something went wrong" assistant turn. If the last persisted message was already an assistant turn (a dangling assistant toolUse, or a prior synthetic error turn), this appends a second consecutive assistant message, breaking Bedrock's strict user/assistant alternation. The next turn then fails and persists yet another assistant error turn — an amplifier that turns one bad turn into a permanently bricked session. Add a centralized role-alternation guard in persist_synthetic_messages via a new last_persisted_role param: any synthetic turn that would land adjacent to a same-role turn is dropped (the error stays a live-only UI affordance, the same choice the max_tokens path already makes). Callers in stream_coordinator pass the history tail role via a new _last_persisted_role(agent) helper. Complements PR #653's restore-time _repair_tool_pairing, which masks this on the model-request path; this fixes the write side so storage and the message display stay clean too. Co-Authored-By: Claude Opus 4.8 * fix(chat): reject duplicate concurrent turns with a per-session single-flight lease A client-side abort (Stop, tab switch, dropped socket, retry) does not propagate through the AgentCore Runtime data plane, and the Runtime can route a duplicate POST /invocations to a different container. Two agent loops then run concurrently against one AgentCore Memory session and corrupt tool-pairing history, which Bedrock Converse rejects on every subsequent turn ("toolResult blocks exceed toolUse blocks"). This bricked prod session f761f59b. Follow-up to PR #653, which closed the frontend tab-switch vector. Add a distributed single-flight guard at the inference-api /invocations turn-start chokepoint: - session_lease.py: acquire/renew/release on a dedicated sessions-metadata item (PK=USER#{uid}, SK=LEASE#{sid}) via an atomic conditional write. leaseExpiresAt is the app-level check; ttl is a coarse auto-reap backstop. Owner-scoped renew and release. Fail-open on any non-conflict DynamoDB error. - routes.py: acquire at turn-start; reject a duplicate with 409. Resume / max-tokens continuation re-enter an already-ended loop, so they acquire with force=True (never blocked, still install a lease). Heartbeat renews the lease while the turn streams; release in the generator finally + both except handlers. Preview / no-DynamoDB paths skip the guard. - SPA: handle the 409 as a soft "Already responding" notice (AlreadyStreamingError) instead of a hard "Chat Request Failed" toast; unwrap the BFF's double-encoded detail; loading clears so the user can retry once the prior turn finishes. Design note + distributed-cancel follow-on: docs/specs/session-single-flight-guard.md Co-Authored-By: Claude Opus 4.8 * feat(chat): distributed turn cancellation — make Stop actually stop the server turn Follow-on to the single-flight lease (#655). A client abort doesn't propagate through the AgentCore Runtime data plane, so Stop was cosmetic server-side: the container ran to completion, held the lease, and burned model/tool spend. That left "Stop → resend" returning 409 until the prior turn finished naturally. Reuse the lease as the cross-container signalling channel: - Signal: the app-api user_stopped endpoint calls request_session_cancel, which stamps cancelRequestedFor= on the lease item (owner-scoped, so a stale Stop can't kill a later turn). Best-effort — never fails the Stop. - Observe: the inference-api lease heartbeat (tightened 30s→10s) renews with ReturnValues=ALL_NEW and, on cancelRequestedFor==owner, flips session_manager.cancelled. - Effect A (tools): the always-on StopHook cancels the next tool call. - Effect B (model stream): a cooperative check at the top of the StreamCoordinator loop raises _CooperativeStopSignal; a dedicated arm persists the partial via _persist_interruption (marked user_stopped), emits terminal SSE frames, and ends cleanly (no re-raise) so a still-connected client closes and the lease releases. This is what ends a pure-chat turn, which has no tool boundary for StopHook. - acquire clears any stale cancel marker (REMOVE) on takeover. Net: Stop ends the server turn; the 409-on-resend window shrinks from a full turn to ~one heartbeat (10s), and wasted spend after Stop is halted. Rides the existing interrupted-turn teardown/persist path (hardened by #653's _repair_tool_pairing), so stopping mid-stream never orphans or corrupts history. Residual (documented): in-flight tool calls finish before cancel is seen; already- generated Bedrock tokens are billed. Design note: docs/specs/session-single-flight-guard.md Co-Authored-By: Claude Opus 4.8 * fix(infra): grant app-api task role access to shared-conversations table The shared-conversations DynamoDB table was threaded into the app-api container as an env var (SHARED_CONVERSATIONS_TABLE_NAME) but never granted on the task role. Every conversation-share operation therefore failed against DynamoDB: - POST /conversations/{id}/share -> PutItem AccessDeniedException - GET /conversations/{id}/shares -> Query AccessDeniedException on the SessionShareIndex GSI Both surfaced to users as a generic 500 "Failed to create share". Add `SharedConversationsAccess` to the app-api coreTables grant list so the role gets the standard DynamoDB action set on the table and its GSIs (index/*), matching every other table the app-api touches. Also add a regression test that synthesizes PlatformStack and asserts the app-api role has a SharedConversationsAccess statement granting PutItem/Query/GetItem with a GSI resource and no wildcard. Verified the test fails without the grant. Co-Authored-By: Claude Opus 4.8 * feat(shares): offload large-conversation snapshots to S3 Sharing a large conversation failed: ShareService.create_share inlined the full message list into a single DynamoDB item, exceeding the 400 KB item limit and surfacing to users as a bare 500 (observed in prod-ai as a PutItem ValidationException). This is separate from the IAM-grant bug in PR #657. Offload the snapshot body (messages + metadata) to a new private shared-conversations S3 bucket, keeping only control fields plus a body_ref pointer in DynamoDB — mirroring the Memory Spaces / Artifacts / Skills S3-offload pattern. Reads fall back to inline for legacy shares, so existing shares keep working with no migration and the SPA contract is unchanged. - New ShareSnapshotStore (content-addressed S3 put/get/delete, SSE-S3, dedupe) - create_share writes body to S3 + body_ref item; revoke/session-cleanup best-effort delete the object - _load_snapshot_body reads from S3 or falls back to legacy inline items - ShareStorageUnavailableError -> friendly 503 instead of a bare 500 - CDK: shared-conversations bucket + SSM param, compute-ref, app-api env (SHARED_CONVERSATIONS_BUCKET_NAME), and SharedConversationsBucketReadWrite IAM grant (app-api only) - Tests: store round-trip/dedupe, >400 KB regression, S3 + legacy reads, export-from-S3, revoke cleanup, storage-unavailable Spec: docs/specs/share-large-conversations-s3-offload.md Co-Authored-By: Claude Opus 4.8 * fix(agents): resolve model provider for agent-bound invocations Agent (assistant) model bindings persist only `model_id` — never `provider` — so previewing/invoking an agent bound to a Mantle model (e.g. `openai.gpt-5.4`) resolved to provider=None. That misroutes the model to Bedrock ConverseStream, which rejects it with "The provided model identifier is invalid", even though the same model works from the normal chat path (which always sends `provider` alongside `model_id`). Two complementary fixes: - Backend (server-authoritative): `_resolve_model_settings` now also returns the model's registered `provider` from the managed-model registry, and the invocation path backfills `effective_provider` from it when the request/binding didn't carry one. This fixes all existing agents with a provider-less stored binding — no data backfill needed — and mirrors how `mantle_api_mode`/`mantle_region` are already recovered. The app-tool-call / app-context-update rebuild paths get the same fallback so a rebuilt agent keys on the same provider as its main turn. - Frontend: the Agent Designer save payload now persists the selected model's `provider` (from the catalog `meta.provider`) alongside `modelId`, so newly created/edited bindings are self-describing. Co-Authored-By: Claude Opus 4.8 * fix(inference): bind effective_enabled_tools on resume path Resume turns (interrupt_responses set — OAuth-gated MCP consent or tool-approval) crashed with `NameError: cannot access free variable 'effective_enabled_tools'`. The variable is referenced unconditionally by the `stream_with_quota_warning` streaming closure (attachment guidance + tabular inventory) but was only assigned in the non-resume branch. On resume the closure raised before its first yield, the inference-api container returned 500, and the AgentCore Runtime data plane translated that into a 424 Failed Dependency to app-api and the SPA. This broke every interrupt-resume turn since the agent-designer tool-binding refactor (0b9b039a) — most visibly "connect to Gmail for employees", which completes via an OAuth-consent resume. Bind effective_enabled_tools from the paused-turn snapshot on the resume branch (the same source the resume get_agent call uses). Adds a resume-path regression test to tests/routes/test_inference.py that drives /invocations with interrupt_responses and asserts a 200 stream; without the fix it fails with the NameError. Co-Authored-By: Claude Opus 4.8 * chore(kaizen): weekly research scan 2026-07-17 Generated by the kaizen-research skill. Top 5 ideas appended to docs/kaizen/review-queue.md for the kaizen-review-prep run later this morning. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: add session-metadata static sort key spec (issue #175) Root-cause spec for the SessionMetadata parse-failure warnings: the session row's sort key encodes lastMessageAt, forcing a delete+put row move every turn. Concurrent writers race that move and upsert bare ghost rows. Also drives the first-turn duplicate-row race. Fix: static SK (S#{session_id}) + sparse SessionRecencyIndex GSI for recency listing. Covers the expand -> migrate -> backfill -> contract migration, downstream/forked-deployment safety (marker gate + graceful GSI-missing fallback), pagination-token compatibility, and the test matrix. Co-Authored-By: Claude Opus 4.8 * feat(infra): add SessionRecencyIndex GSI to sessions-metadata (issue #175 Phase 0) Sparse recency index (GSI4_PK=USER#{id}, GSI4_SK={lastMessageAt}#{session_id}, projection ALL) for newest-first active-session listing once the base sort key becomes static. Phase 0 of the static-sort-key migration: adding the index is a no-op until rows populate GSI4 keys, so it deploys safely ahead of any code change. IAM already covers it via the SessionsMetadataAccess /index/* wildcard. Update tables-detailed test to assert all four GSIs (the "2 GSIs" title was already stale after DueScheduleIndex) and the new index's key schema. Co-Authored-By: Claude Opus 4.8 * feat(sessions): dual-scheme union read for session listing (issue #175 Phase 1a) Expand-read step of the static-sort-key migration. list_user_sessions now reads the UNION of two disjoint sources so a session is visible whether or not its base sort key has been migrated to the static S#{session_id} form: - legacy (un-migrated): base table, SK begins_with 'S#ACTIVE#' - migrated: SessionRecencyIndex GSI (GSI4_PK=USER#{id}, GSI4_SK={lastMessageAt}#{id}) Pagination switches to a value cursor ({lastMessageAt}#{session_id}) so each page is derived independently from the last returned position, with no cross-page buffering; fetching limit+1 valid rows per source is provably enough to detect a next page. The cursor decoder is tolerant — legacy/undecodable tokens fall back to first page (a harmless reset across the deploy boundary). Degrades to legacy-only if SessionRecencyIndex doesn't exist yet (code ahead of the CDK GSI): the GSI query's ResourceNotFoundException is caught. No writes change and no row migrates in this phase — this only teaches every reader to cope with both schemes, which must be fully rolled out before Phase 1b turns on self-migrating writes. Tests: union ordering, cross-union pagination (no dupes/gaps), migrated-only via GSI, ghost/preview skip, and graceful fallback when the index is absent. conftest sessions_metadata_table fixture gains the SessionRecencyIndex GSI to match prod. Co-Authored-By: Claude Opus 4.8 * fix(deps): bump strands-agents to 1.48.0 for cachePoint-attachment fix Auto prompt caching (CacheConfig strategy=auto) appended its cachePoint after the last user message's content, so any turn attaching a non-PDF document (txt/docx/csv/...) sent [text, document, cachePoint] and Bedrock's Anthropic adapter rejected it with "ValidationException ... messages.N.content.M.type: Field required", surfacing to users as "Agent force-stopped" (prod incidents Jul 14-16, e.g. session dd1a647a on a .txt transcript upload). strands 1.48.0 places the cache point before the first non-PDF document block instead (upstream issue #1966); every placement it produces was verified live against global.anthropic.claude-sonnet-4-6 ConverseStream. Also corrects the model_config comment that credited PR #1438/1.39.0 with this fix - #1438 was the auto-caching feature itself. Co-Authored-By: Claude Fable 5 * fix(sessions): degrade to legacy-only on real ValidationException for missing GSI (issue #175) The Phase 1a dual-scheme read (PR #667) catches a missing SessionRecencyIndex to fall back to legacy-only listing, but only handled ResourceNotFoundException — what moto raises. Real DynamoDB raises ValidationException ("The table does not have the specified index") for a missing GSI (verified against the prod table). So if the 1a backend deployed to an environment before the CDK GSI existed, list_user_sessions would 503 instead of degrading. Broaden the catch to also handle ValidationException (scoped by the "specified index" message so genuinely malformed queries still surface). This restores the intended order-independence: 1a is safe whether or not SessionRecencyIndex exists yet, which matters for prod deploy ordering (backend.yml vs platform.yml) and for forked deployments. Add a test that reproduces the real ValidationException on the index query (moto masks it), asserting fallback to legacy-only results. Co-Authored-By: Claude Opus 4.8 * Add Word document tools (create/modify/list/read) Provision a full Word (.docx) toolset behind the single create_word_document capability toggle. Each tool runs python-docx in Bedrock Code Interpreter and uses the existing user-files store (S3 + DynamoDB) for persistence and delivery. - create/modify/list/read tools in agents/builtin_tools/word_document_tool.py, injected per-request via _build_word_document_tools (inference_api/chat/routes.py). - Frontend inline-visual 'word_document' renderer with an accessible download button (Tailwind utilities, no scoped CSS). - Restore-time content-block sanitizer in TurnBasedSessionManager: drops empty/typeless blocks from restored history that caused Bedrock ConverseStream 'messages.N.content.M.type: Field required'. - Seed create_word_document in bootstrap DEFAULT_TOOLS ('Word Documents') + updated seed tests. * feat(sessions): static-SK write path — born static, self-migrate, no rotation (issue #175 Phase 1b) Turns on the write side of the static-sort-key migration. Sessions stop encoding lastMessageAt in the sort key, so the row never moves and the ghost-row race that produced "Failed to parse session item" warnings is structurally eliminated for every migrated row. Changed (all resolve the row via GSI, which is SK-scheme-agnostic): - ensure_session_metadata_exists: new sessions born at static SK S#{id} + GSI4 keys, with a real attribute_not_exists(PK) conditional put. The deterministic SK makes the guard meaningful, closing the first-turn duplicate-row race the old timestamped SK made impossible to gate. - update_session_activity: drops the per-turn Phase-B rotation. Static rows update in place (SET GSI4_SK re-positions the sparse recency index — no row move); a still-legacy row does its one-time final rotation to the static SK, carrying any concurrent write. - _store_session_metadata_cloud: static SK; migrate legacy->static on move; SET GSI4 for active, REMOVE for deleted; never un-migrates a static row. - session_service.delete_session: resolves the raw SK via _get_session_by_gsi instead of reconstructing S#ACTIVE#{lastMessageAt}#{id} (which misses migrated rows). Non-rotating soft-delete: SET status=deleted + REMOVE GSI4 in place, or migrate a legacy row to a static tombstone. Drops the S#DELETED# prefix (nothing reads it). The ~10 other writers resolve-then-update-in-place on the current SK and need no change — they already work on a static SK and never rotate. Tests: TestWriteSideMigration (born-static, one-time migrate, no rotation, soft-delete in-place/legacy, end-to-end create->activity->list->delete) plus the real ConditionalCheckFailedException contract (moto raises it for a failed conditional put). Updated three tests that encoded the old rotation contract. Full shared+routes suites: 1689 passed. Co-Authored-By: Claude Opus 4.8 * Fix S3 PutObject PermanentRedirect in Word tools The user-files S3 client pinned its endpoint to https://s3.{AWS_REGION}.amazonaws.com. In the AgentCore Runtime AWS_REGION does not reliably match the bucket region, and the explicit endpoint_url disables botocore's automatic S3 region redirect, so PutObject failed with PermanentRedirect. Resolve the bucket's real region via HeadBucket (x-amz-bucket-region header; maps to s3:ListBucket, which the runtime role already has — GetBucketLocation is not granted) and pin the client to it, dropping the hardcoded endpoint_url. Fixes both the save and the presigned download URL region. * feat(scripts): static-SK backfill for the cold tail + ghost cleanup (issue #175 Phase 2) One-shot, idempotent, throttled backfill that finishes the migration for rows the lazy write-path (Phase 1b) hasn't touched: rewrites legacy S#ACTIVE#/S#DELETED# session rows to the static S#{id} scheme (populating GSI4 for active, none for deleted), deletes the ghost/stub rows the old rotating-SK writers produced, and — only once a fresh scan finds zero legacy rows — writes the migration-complete marker that unblocks Phase 3. Safety: - Dry-run by default; --apply required to write. - Static put uses attribute_not_exists(SK) so it never clobbers a row a live writer already migrated with fresher data; the legacy delete is an idempotent no-op if already gone. - --sleep throttles; re-runnable to convergence. - Marker gated: --set-marker re-scans and withholds the marker while any legacy row remains, so Phase 3 can't be unblocked on partially-migrated data. Tested: 10 moto cases (classification, dry-run no-op, active+deleted migrate, ghost delete, idempotency, conditional-put skip of a live-migrated row, marker gating). Also validated as a dry-run against real dev-ai data (145 legacy rows, 0 ghosts) to confirm the real-DynamoDB scan/filter behavior. Co-Authored-By: Claude Opus 4.8 * feat(sessions): contract session list to GSI-only once migration completes (issue #175 Phase 3) Final phase of the static-sort-key migration. list_user_sessions now reads the SessionRecencyIndex GSI alone once the Phase 2 backfill has set the migration- complete marker (PK=MIGRATION#session-sk, SK=STATE, complete=true) — the legacy S#ACTIVE# union branch is only queried until then. - The marker check is memoised per-process (the marker only ever goes unset->set, never back), so migrated deployments pay no extra read after the first observation; a container that started pre-backfill picks up the flip on a later call. - Fails open: any error reading the marker keeps dual-read. Downstream/forked deployments that haven't run the backfill stay in dual-read, so removing the legacy branch here can never blank an un-migrated sidebar. - Safety net: even with the marker set, if the GSI query itself errors (transient ValidationException/ResourceNotFound) the legacy branch is still queried, so a flaky index never returns an empty list. The legacy code path is retained behind the marker rather than deleted, per the downstream-safety design; a later release can drop it once all deployments report the marker set. Tests: dual-read when marker absent, GSI-only (legacy row excluded) when set, marker memoisation, and GSI-failure-falls-back-to-legacy-even-with-marker. Full sessions+routes+backfill+architecture suites: 136 passed. Co-Authored-By: Claude Opus 4.8 * docs(specs): skills v2 — skills as a pure knowledge primitive bound on Agents Supersedes the tool-binding sections of admin-skills-rbac-tool-binding.md and the mode-toggle design in skills-mode.md. Skills become agentskills.io knowledge bundles (no bound_tool_ids), bound on Agents via the Designer, with a user-uploaded tier, opt-in chat selection, Strands AgentSkills plugin runtime, and invoke-through sharing for shared Agents. Co-Authored-By: Claude Fable 5 * refactor(agents): delete MCP tool-folding machinery and hook shims (skills v2 PR-1) Skills no longer bind tools, so the entire fold stack that served bound_tool_ids goes: mcp_binding.py (FoldedMCPTool, resolve_mcp_bindings, the two folded-tool lookup factories), mcp_tool_folding.py and its drop_folded_tools calls in FilteredMCPClient / UICapableMCPClient, the tool_use_provider_lookup / tool_use_approval_lookup shims on OAuthConsentHook / MCPExternalApprovalHook (+ FoldedToolApproval), and their wiring in base_agent. SkillAgent is neutered, not deleted (PR-2): DB-backed skills are instructions-only; the file/dev @skill binding path is unchanged. SkillRegistry loses all_bound_tool_ids/bind_catalog_tools. Note: the spec's stream_coordinator skill_executor unwrap item has no corresponding code — nothing existed to delete there. Per docs/specs/skills-as-agent-primitive.md §2/§8 (PR-1). Co-Authored-By: Claude Opus 4.8 * refactor(skills): remove bound_tool_ids end-to-end (skills v2 PR-1) A skill is a pure knowledge bundle: drop bound_tool_ids from SkillDefinition, the create/update/response DTOs and the Dynamo (de)serialization; delete _validate_bound_tools and the boundToolCount projection; stop emitting boundToolIds in the bindable-catalog meta; seed web_research as an instructions+reference-file bundle with no bound tool. SPA: remove the boundToolIds field/model plumbing, the skill-form bound tools section, the list-page badge, and the tool-picker dialog. Existing rows with boundToolIds deserialize fine (attribute ignored); no production data binds tools to skills (SKILLS_ENABLED=false everywhere). Co-Authored-By: Claude Opus 4.8 * refactor(chat): remove skills mode — policy, toggle, and preference (skills v2 PR-1) Skills are opt-in per turn, not a mode. Delete the admin chat-mode policy (platform_settings sentinel + admin /settings/chat routes + public /system/chat-settings), _resolve_effective_agent_type, and the preferred_agent_mode user setting. DEFAULT_AGENT_TYPE flips to "chat"; an explicit agent_type="skill" (future picker / agent-binding resolver) still resolves skills, and the enabled_skills request plumbing + skills_hash caching are kept verbatim for the PR-4 picker. SPA: delete ChatModeService and the Skills/Tools capabilities toggle; Skills + Tools sections render unconditionally (Skills gated on having skills or an agent lock, so it stays inert while the feature is off); stop sending agent_type/enabled_skills; drop preferredAgentMode and the session-preference agentType plumbing. Agent-bound skill locks now load the skill list on demand so locked rows render their names. Co-Authored-By: Claude Opus 4.8 * docs(specs): mark skills-mode and tool-binding specs superseded by skills v2 Co-Authored-By: Claude Opus 4.8 * feat(skills): swap runtime to Strands AgentSkills plugin (skills v2 PR-2) Spike gate PASSED (20/20, docs/specs/skills-v2-pr2-spike-findings.md): the vended AgentSkills plugin composes with our prompt assembly (block-level injection preserves cache points), the skills_hash cache key, paused-turn resume, and agent.state round-trips our TurnBasedSessionManager unchanged. Runtime swap: - Map DB SkillDefinition -> strands.Skill via a new skills/strands_mapping.py (slugged name for agentskills.io validity + S3/harness portability; true skill_id + human display_name carried in metadata). Add advisory allowed_tools + skill_metadata frontmatter passthrough to the model (D1/D4). - ChatAgent conditionally adds AgentSkills(skills=[...]) when the turn carries accessible_skill_ids; AgentFactory.create_agent gains a plugins param. - Retire the homegrown disclosure stack: SkillAgent, skill_registry.py, skill_tools.py (skill_dispatcher/skill_executor), the @skill decorator + file/dev definitions, and their tests. "skill" stays a registered alias -> ChatAgent so the existing agent_type/skills_hash cache-key + resume path (which the spec keeps) resolve to a ChatAgent-with-plugin unchanged. read_skill_file (L3 reference bytes) and the S3 SKILL.md write-through projection follow as separate PR-2 commits. Full backend suite green (4611 passed, 3 skipped). Co-Authored-By: Claude Opus 4.8 * feat(skills): standard bundle layout + read_skill_file (skills v2 PR-2) Completes the PR-2 runtime: reference-file disclosure (L3) and the agentskills.io bundle layout that makes each skill a portable artifact. S3 bundle layout + SKILL.md projection (#6): - SkillResourceRef gains `kind` (reference|script|asset); dynamo round-trips it, old rows default to reference. - resource_store keys files at skills/{id}/{references|scripts|assets}/{filename} (path-based, dedupe dropped for the readable standard layout); add put_skill_md and resource_key/skill_md_key helpers. - create_skill/update_skill write a SKILL.md projection generated from the row (new apis/shared/skills/bundle.py: slugify + generate_skill_md) — best-effort, never fails the catalog write. Admin upload route gains an optional `kind`. read_skill_file (#4): - New per-turn tool (agents/main_agent/skills/strands_mapping.build_skills_runtime returns plugin + tool from one record fetch). Resolves `path` against the skill's manifest (no filesystem/traversal), serves bytes from SkillResourceStore, labels scripts inert (D5), describes binary assets instead of dumping bytes, and is implicitly access-gated (bound only to the turn's effective records — richer §6 invoke-through is PR-4). Skill.instructions gain an "Available reference files" listing so the model knows what to request. - ChatAgent wires read_skill_file alongside the AgentSkills plugin. slugify moved to apis/shared/skills/bundle (shared by the app-api projection and the agents runtime; import-boundary safe). Full backend suite green (4634 passed). Co-Authored-By: Claude Opus 4.8 * docs(specs): mark skills v2 PR-2 done in the plan Co-Authored-By: Claude Opus 4.8 * fix(agents): correct stale skills copy and surface invalid-save feedback Two issues found while smoke-testing skills v2 PR-2 (#681). Skills v2 decision D1 removed skill->tool binding entirely: a skill is a pure knowledge bundle and never grants or carries a tool. Two surfaces still claimed otherwise -- the Agent Designer skills section ("with their own bound tools") and the admin skill edit form ("and bound tools"). Saving an invalid agent form also no-opped silently: persist() marked the controls touched and returned, so the only feedback was an inline error that is usually below the fold once the author has scrolled to the Model/Skills sections. The dirty banner stayed up, making the click look ignored. Now an invalid save also toasts and scrolls the first invalid control into view. The reveal helper matches on input/textarea/select rather than [formControlName]: the starters array binds [formControlName]="$index", a property binding that renders no attribute to select on. Verified with npm run build and ng test (127 files, 1452 tests passing). Co-Authored-By: Claude Opus 4.8 * feat(skills): user-authored skills tier (skills v2 PR-3) Adds the owner-scoped half of the skill catalog: any user can author their own agentskills.io knowledge bundles and reach them at runtime, without an admin RBAC grant. Backend - `list_skills_by_owner` — GSI4 (SkillOwnerIndex) partition query, the "list my skills" path. `list_skills` gains an `owner_id` filter. - `UserSkillService` — owner-scoped CRUD. Ownership is resolved on every path; a skill you do not own is 404, never 403, so the surface never confirms someone else's skill exists. Resource handling (caps, manifest, bundle layout, orphan GC) delegates to SkillCatalogService so both tiers emit identical bundles. - `/skills/mine/*` routes on the existing session-auth router, so they inherit the SKILLS_ENABLED mount gate. - Skill ids are allocated server-side from the display name and suffixed on collision (docx -> docx_2). Ids stay globally unique because the runtime activation key is the slugified id, and a 409 would disclose the existence of a skill the user cannot see. - `resolve_accessible_skill_ids` now returns catalog ∪ own — ownership is its own grant. This is what makes an authored skill usable at all. Two tier boundaries closed, both of which would have leaked private skills: - `get_all_skill_ids` (RBAC "*" wildcard expansion) now lists only catalog skills; a wildcard grant must not sweep in other users' authored skills. - The admin role-grant endpoints refuse user-authored skills, since granting one to an AppRole would hand a private document to a whole role. The admin catalog list is likewise scoped to owner_id == "system". Frontend - My Skills page (list + create/edit form) with SKILL.md import prefill and reference/script/asset uploads; scripts are labeled non-executable. - Nav entry gated on the same 404 accessibility probe memory-spaces uses. Co-Authored-By: Claude Opus 4.8 * fix(skills): preserve SKILL.md frontmatter through import (skills v2 PR-3) Live clickthrough found an imported bundle losing everything outside name/description: a SKILL.md carrying `license: MIT` round-tripped back out of S3 without it. Spec D2 requires import/export to be round-trip-faithful, and the backend already had `skill_metadata` + `allowed_tools` columns for exactly this — only the client-side import never populated them, leaving both fields dead on the user tier. - `parseSkillMarkdown` now also returns `allowedTools` (comma-separated or inline-array forms) and `metadata` (every non-reserved frontmatter key). Additive to the DTO, so the admin form is unaffected. - The My Skills form carries both through create and update, including for a loaded skill, so an edit never silently drops them. - Advisory tools render as chips with the D1 disclaimer — skills never grant tools; the bound agent decides. Also fixes a `capitalize` on the staged-file line title-casing the whole string ("Uploads When You Save"); only the kind should capitalize. Verified: a bundle with license + compatibility + allowed-tools now emerges from the S3 SKILL.md projection intact. Co-Authored-By: Claude Opus 4.8 * chore(skills): backfill script for v1 skill bundles (skills v2 PR-3) Skills authored before PR-2 have neither the SKILL.md write-through projection nor the standard bundle layout — v1 stored resources content-addressed (skills/{id}/{sha256}) with no `kind`. Their S3 prefix is therefore not a valid agentskills.io bundle: it can't be handed to a managed Harness or exported as-is, which is the whole point of the projection. The script fixes both per skill: copies each legacy object to its standard path, rewrites the row's manifest to point there (adding `kind`), and writes the SKILL.md generated from the row. It imports `generate_skill_md` and the key helpers from the live write path, so a backfilled bundle is byte-identical to one the app writes today. Follows the backfill_session_static_sk conventions: dry-run by default, idempotent, throttled, scopeable to one skill. Copies are non-destructive — legacy objects survive unless --delete-legacy, and a manifest entry whose bytes are missing is left untouched rather than repointed at nothing. Applied to dev-ai/web_research: manifest now points at references/extraction_tips.md, SKILL.md written, read path verified at 200. Co-Authored-By: Claude Opus 4.8 * fix(skills): seed example skill as a v2 bundle (skills v2 PR-3) Backfilling dev's web_research row surfaced that the seeder itself still emits v1 shapes, so every fresh environment reproduces exactly the state the backfill just repaired: - resources landed at the content-addressed key (skills/{id}/{sha256}) with no `kind`, instead of skills/{id}/references/{filename} - no SKILL.md was ever written, so the seeded prefix was not a valid agentskills.io bundle and could not be handed to a managed Harness or exported as-is Both fixed. The slug and frontmatter rules are duplicated from apis/shared/skills/bundle.py rather than imported, because seed.sh runs this script standalone after infra deploy without the app package on the path — the existing content-hash logic was duplicated for the same reason. Tests now pin the standard layout and the projection, plus a guard that the seed prose never again names the retired v1 meta-tools (skill_executor / skill_dispatcher). That drift is what left dev's row instructing the model to call tools deleted in PR-1/PR-2. Note the seeders are skip-if-exists, so this repairs new environments only; existing ones need backfill_skill_bundles.py (dev-ai: applied). Co-Authored-By: Claude Opus 4.8 * feat(skills): selection surfaces + invoke-through access (skills v2 PR-4) Wires the chat opt-in picker end-to-end and lands the §6 invoke-through access predicate. Per spec docs/specs/skills-as-agent-primitive.md §8. The picker's markup shipped in PR-1 but nothing ever sent its selection, so the plain-chat skills path had never actually run. Turning it on surfaced three latent bugs: - Skill resolution was gated on agent_type == "skill", so the picker could never have taken effect. Skills are now driven by the selection on any turn; agent_type gates nothing ("skill" stays a ChatAgent alias only so stale SPA sessions don't 422). - The binding resolver gated on AppRoleService.can_access_skill, which has no ownership clause — an author was blocked on their own authored skill when invoking their own Agent — and whose "*" wildcard matched any id at all, including another user's private skill. Clauses 1+2 now route through resolve_accessible_skill_ids, which expands "*" over the catalog only. - Paused-turn resume and the construction snapshot both keyed skills off agent_type == "skill", which would have orphaned the paused agent of any plain-chat turn carrying skills. Both now key off the snapshot's own enabled_skills. Invoke-through (D7) is deliberately AGENT-scoped: it lives in the binding resolver, not in resolve_accessible_skill_ids, because widening the shared resolver would leak an Agent owner's private skills into every invoker's plain-chat picker and bindable palette. The owner-match clause blocks chain-sharing, and a system-owned Agent gets no invoke-through at all so RBAC stays the sole gate on catalog skills. D6 default flip: an absent or empty enabled_skills means no skills, on both the runtime filter and the picker's untouched-preference default. The two must agree or the UI would show skills as active that the turn never loads. It also keeps skills free for turns that don't want them — an absent selection short-circuits before any RBAC or skill-table read. read_skill_file needed no per-call predicate: its record set IS the turn's effective skill set, so there is no id the model can name to reach a skill the invoker cannot use. The Designer palette union needed no code — /agents/bindable already delegates to resolve_accessible_skill_ids, which PR-3 widened. Backend 4714 passed; SPA 1469 passed across 128 spec files. Co-Authored-By: Claude Opus 4.8 * chore(assets): add GitHub connector logo variants Light/dark Octocat marks alongside the existing google-* connector logos. Follows the repo's theme convention: -light is the black glyph (for light backgrounds), -dark the white one. Nothing references these yet — they're staged for a GitHub connector. Unrelated to the skills work in this branch; riding along rather than sitting untracked. Co-Authored-By: Claude Opus 4.8 * feat(skills): enable Skills v2 + admin-only capability gate (skills v2 PR-5) Flips SKILLS_ENABLED to default-ON with a kill switch and adds the infrastructure wiring it never had, closing out the Skills v2 epic (spec docs/specs/skills-as-agent-primitive.md §8). SKILLS_ENABLED had zero CDK/workflow plumbing, so "enable it per environment" was not previously expressible. Adds SkillsConfig to config.ts, threads SKILLS_ENABLED into both app-api and inference-api (they must stay in step — design-time refuses to bind a skill while the flag is off, so a mismatch would let an Agent be built with skills the runtime then blocks), and forwards CDK_SKILLS_ENABLED in platform.yml with the empty-string-safe ternary an unset GitHub variable requires. Feature existence and audience are two independent controls. The flag says the feature exists in an environment; the new `skills` RBAC capability says who sees the user-facing surfaces. system_admin holds it implicitly via its "*" tools grant, so the picker and My Skills stay admin-only during rollout; GA is one grant of `skills` to the `default` role, no redeploy. The gate raises 404, not 403. The SPA hides the My Skills nav entry by riding the list call, so a 404 hides the surface while a 403 surfaces an error toast — the failure mode that got the scheduled-runs capability gate reverted in prod. It also deliberately does not gate the runtime: an Agent shared to an ordinary user must still resolve its bound skills (invoke-through, §6/D7), and a capability check there would break exactly that path. Verified live end to end against a real agentskills.io bundle (Anthropic's docx) uploaded as a user skill and bound to an Agent: L1 8,075 -> L2 9,835 (skills tool, SKILL.md body) -> L3 10,737 (read_skill_file on references/LICENSE.txt). Invoke-through confirmed with a second non-admin account — the grant resolves through the shared Agent while the same skill stays absent from that user's own picker and /skills/mine. The "session auth, not Bearer" assertion now walks the transitive dependency tree rather than each route's direct dependencies, since the routes hang off the capability gate which in turn depends on the session. Pins the invariant that actually matters instead of the shape. Co-Authored-By: Claude Opus 4.8 * refactor(rbac): remove dead AppRoleService.can_access_skill Skills v2 moved skill authorization to apis/shared/skills/access.py (resolve_accessible_skill_ids = catalog ∪ own, and resolve_invocable_skill_ids which adds the Agent-owner invoke-through clause). AppRoleService.can_access_skill has had zero production callers since PR-4 and is wrong on two axes for anything user-tier: no ownership clause, and its "*" wildcard matches ANY skill id including another user's private authored skill. - Delete the method and its three tests. test_can_access_skill_with_wildcard asserted can_access_skill(user, "any_skill") is True — it enshrined the wildcard over-expansion bug as expected behavior. - Fix two stale docstring/comment references in agent_designer's binding_validation.py that still named it as the live run-time mechanism; since PR-4 that is resolve_invocable_skill_ids. - Reword the intentional "deliberately NOT can_access_skill" rationale in skills/access.py and agent_binding_resolver.py to past tense so they no longer imply the function still exists. Co-Authored-By: Claude Opus 4.8 * refactor(skills): delete dead SkillAccessService Skills v2 moved skill authorization to apis/shared/skills/access.py (resolve_accessible_skill_ids / resolve_invocable_skill_ids). SkillAccessService was left behind with zero live callers — not exported from admin/services/__init__.py, not DI-wired, and reached by no dynamic import. Its can_access_skill carried the same "*"-wildcard over-expansion flaw that got AppRoleService.can_access_skill deleted in #686. Also updates the stale comment in admin/skills/routes.py that named the service as the consumer of the all-skill-ids snapshot; that snapshot is still live, but its reader is now skills.access. Co-Authored-By: Claude Opus 4.8 * docs(agents): draft the Agent Directory spec A browse-and-discover surface for published Agents: a directory page and a detail page modeled on the ChatGPT/Claude plugin-detail layout, built as a read-view over the Agent record rather than a new primitive. The central decision (D1) is that we do NOT introduce a "Plugin" noun. Both vendors need a bundle layer because their capabilities install into a workspace separately from any persona; our Agent's `bindings[]` already IS that bundle, attached to the persona. Every field a vendor plugin-detail page renders already exists on our record. Grounding findings that shaped the design: - `VisibilityStatusIndex` (GSI2) is live and populated, and PUBLIC *access* still resolves to "viewer" — only the *listing* was switched off in ad4437e9 when email sharing superseded a public index. The read path is mostly built. - Listing is nonetheless a new sparse GSI5, not a re-enable: `VISIBILITY#PUBLIC` is one hot partition and can't be filtered by category. GSI5 is the next free slot (GSI4 is DueSyncIndex), and DueSyncIndex on the same table is the precedent — unlisted agents have no key, so the query physically can't see them. - `listed` is deliberately separate from `visibility` (D3). Deriving listing from PUBLIC would retroactively publish every existing PUBLIC agent to the whole institution with no author consent; backfill is listed=false. - Publishing amplifies Skills v2 invoke-through from a typed email list to everyone, so the publish dialog must enumerate which authored skills it exposes, and memory_space bindings block publication outright (D5). - The Designer's block-on-missing rule (D5) strains under open browsing, so the detail page previews per-invoker runnability up front — which resolves the "per-invoker capability preview" open question parked in agent-designer.md. Also notes a behavior change needing a call: GET /agents/{id} currently returns `instructions` to any PUBLIC viewer, which is a much larger exposure once agents are broadly listed than it was under link-sharing. Co-Authored-By: Claude Opus 4.8 * refactor(artifacts): collapse the two catalog rows into one Artifacts toggle Artifacts shipped as two independent `protocol: local` catalog rows — `create_artifact` and `update_artifact` — so the tool picker listed them as two unrelated entries. The picker only groups children under a parent for MCP protocols (driven by `serverTools`), so there was nothing to nest them under; the flat listing was a data-model fact, not a template gap. Adopt the Word-documents idiom already used one entry below in the seed list: a single catalog row whose id is the gate key, with the runtime injecting the full toolset. `create_artifact` is now that key and provisions both the create and update tools. `seed_default_tools` is create-only, so a seed run does nothing to an environment seeded before this change. Add a backfill script that retitles the surviving row, promotes `update_artifact` to `create_artifact` everywhere a grant can hide — role TOOL_GRANT# items *and* the grantedTools/effectivePermissions.tools arrays on DEFINITION, user toolPreferences, assistant bindings — then deletes the retired row. Promote-before-delete, so an aborted run degrades to "both granted", never "neither". Schedule snapshots are left alone: a stale id there is an inert no-op since the runtime only reads `create_artifact`. Two judgement calls worth recording: - User prefs are a sparse override map, so only an explicit *enable* of the retired id carries over. Someone who switched update off while leaving create at its default-on never asked to lose artifacts, so an explicit disable just drops the key. - A role granting `*` gains no concrete grant — the wildcard already covers the keeper and narrowing it would be a silent scope change. Behavior change: anyone with create enabled but update disabled now gains update. That is inherent to collapsing the toggle. Co-Authored-By: Claude Opus 4.8 * chore(sidenav): hide the My Skills nav entry The /my-skills route, page, service, and the whole /skills/mine backend surface stay fully functional — only the sidenav link is removed, until we decide how users should actually navigate to their skills. Drops the now-dead showMySkills computed, the MySkillService injection, and its loadSkills() accessibility probe (that call existed solely to decide whether to render the link). Co-Authored-By: Claude Opus 4.8 * docs(skills): draft the Skill Creator spec Adopts the authoring half of Anthropic's open-source skill-creator and scopes out the eval half, which assumes a filesystem, subagent spawning, and script execution — none of which exist here (scripts are inert by design). Evals hang off F1's headless lane instead. PR-1 ships the methodology as an admin-catalog skill with zero code, routing the handoff through the My Skills form's existing frontmatter parsing. PR-2..PR-4 add the missing primitive: agent tools that write the user's own skills, executing as the invoking user and re-checking ownership per call, mirroring memory_write. Depends on skill-bundle-import's nested paths and server-side SKILL.md parser rather than restating them. Co-Authored-By: Claude Opus 4.8 * feat(skills): drop the `skills` capability gate from user-facing routes The user-facing skills surfaces (`GET /skills/`, `PUT /skills/preferences`, and all of `/skills/mine/*`) hung off `require_skills_capability`, which 404'd anyone not holding the `skills` RBAC capability. Its job was to keep skills admin-only during the v2 rollout, with GA framed as "one grant of `skills` to the `default` role, no redeploy." That GA path does not work, for two independent reasons: 1. `default` is a *fallback* role. `resolve_user_permissions` consults it only when a user matches zero AppRoles (service.py "Step 3"); it is not merged alongside a matched role. Prod's `default` carries no JWT mappings at all, so granting there would reach only unmapped users — never the faculty/staff/student cohorts. 2. A capability id cannot be granted from the admin roles UI regardless. That form builds `grantedTools` from the tool catalog, and a capability is not a tool, so there is no way to select it and no free-text entry. Net effect: an admin who granted a catalog skill to a role would find it silently invisible to that role's users, with no in-product way to fix it. Remove the gate. Skills are governed by `SKILLS_ENABLED` per environment and by a role's `grantedSkills` per cohort — a complete model that the admin UI can actually operate. Both surfaces are already self-limiting: `GET /skills/` returns only what `resolve_accessible_skill_ids` grants (no grants means an empty list and no rendered picker), and every `/skills/mine/*` route is owner-scoped inside `UserSkillService`. The route-coverage control is kept rather than dropped, retargeted from "every route is capability-gated" to "every route requires a session" — the invariant that still matters now that the session dependency is the only thing between these routes and an anonymous caller. A second test pins the removal so the gate cannot creep back without also making capabilities grantable from the roles UI. Also corrects the "GA = grant to `default`" claim where it appeared in capabilities.py, infrastructure/lib/config.ts, and platform.yml, and notes that `SCHEDULED_RUNS_CAPABILITY` is itself unused (that gate was dropped after 403ing in prod), which leaves capabilities.py with no consumers. Co-Authored-By: Claude Opus 4.8 * refactor(rbac): delete the dead capabilities module `apis/shared/rbac/capabilities.py` has no consumers left. It defined two capability ids granted through the `grantedTools` axis: * `SCHEDULED_RUNS_CAPABILITY` was orphaned when the RBAC gate on `/schedules` and `/runs` was dropped after 403ing in prod. * `SKILLS_CAPABILITY` was the last live caller, removed in the preceding commit along with `require_skills_capability` and its 12 route deps. Delete it rather than keeping it as a reference. The mechanism it documented is not one we want reached for again: a capability id cannot be granted from the admin roles UI (that form builds `grantedTools` from the tool catalog and offers no free-text entry), so any gate built on it is operable only by hand-writing DynamoDB items. Both live gates were removed for that reason. Its docstring carried two findings that cost real effort to establish, so they move to `AppRoleService.resolve_user_permissions` — the code they actually describe — rather than dying with the file: 1. `default` is a *fallback*, not a universal role. Step 3 substitutes it only when a user matched zero AppRoles, and prod's `default` carries no `jwtRoleMappings`, so granting there reaches only unmapped users. The "GA = one grant to `default`, no redeploy" framing that appeared in several comments was wrong. 2. The roles-UI limitation above, recorded where someone would see it before routing a new grant through this axis. The algorithm list in that docstring is renumbered to match the code's own step comments, so the "Step 3" reference is unambiguous. Also corrects both `feature_flags.py` docstrings, which still described the now-deleted capability as the companion "who may use it" control: `skills_enabled` points at a role's `grantedSkills`; `scheduled_runs_enabled` notes the flag is now the only control and the routes are deliberately ungated. Co-Authored-By: Claude Opus 4.8 * fix(chat-input): make the textarea scrollable and reset it after submit Three defects in the chat input's auto-sizing: - The textarea carried `overflow-hidden`, so once content exceeded the visible area there was no way to scroll within it. - `onTextareaInput` set `height = scrollHeight` with no clamp. Past 200px the inline height kept growing while `max-height` capped the rendered height, leaving the two diverged and the scrollbar unreachable. The growth is now clamped in a shared `autoResize()`. - `submitChatRequest` cleared the value but left the stale inline height, so the input stayed expanded after sending. Adds `resetTextareaHeight()`. Also drops the `.chat-textarea` CSS block: it declared its own min-height/max-height/field-sizing but the class is applied nowhere in the template, so the rules were dead while contradicting the real inline styles. The JS path is now the sole sizing authority. The `isExpanded` signal goes too — it was written on submit and never read. Co-Authored-By: Claude Opus 4.8 * fix(compaction): make restored history byte-stable to preserve Bedrock prompt-cache hits Tool-content truncation previously ran on every session restore behind a sliding protected-turns window, so each new turn re-mutated the turn that just aged past the window. Bedrock prompt caching requires an exact prefix match, so this forced a full prefix cache re-write (~$2.5/MTok on a 35k-150k prefix) nearly every turn — costing far more than the read tokens truncation saved (evidence: prod session aecd387d, inter-turn prefix shrinkages of -382/-1035/-1513 with cacheRead=0 inside the cache TTL). Redesign: truncation is now driven only by a persisted truncation_anchor in the compaction state (sessions-metadata `compaction` attribute): - The anchor moves when the checkpoint advances (update_after_turn), where the slice already pays the single cache re-write — one mutation per compaction event instead of one per turn. - It also advances opportunistically at restore when more than cache_ttl_seconds (default 300s, AGENTCORE_MEMORY_COMPACTION_CACHE_TTL_SECONDS) have passed since the previous turn: the cache entry has expired anyway, so pending truncations are applied for free. - Legacy state records without the field default the anchor to the checkpoint, so retained history stops being mutated immediately on upgrade. - The compaction-failure path in initialize() now resets _compaction_state_loaded so update_after_turn re-loads persisted state instead of overwriting checkpoint/anchor with defaults. Removes the now-dead _find_protected_indices sliding-window helper and adds tests/agents/main_agent/session/test_compaction_stability.py asserting agent.messages is byte-identical across consecutive restores whenever no compaction-state change occurs. Co-Authored-By: Claude Fable 5 * fix(skills): deterministic skill ordering to preserve Bedrock prompt cache Skill records reached the AgentSkills system-prompt block in nondeterministic order, changing the prompt between turns of the same session and invalidating the Bedrock prompt cache (exact-prefix match) — forcing full cache re-writes on turns well inside the TTL. Two sources, fixed at three layers: - batch_get_skills returned raw DynamoDB batch_get_item response order; now sorted by skill_id. - resolve_user_permissions built grant unions as sets and returned list(set) — iteration order varies per process via hash randomization; tools/models/skills now sorted. - build_skills_runtime sorts records before constructing AgentSkills as defense in depth at the injection point. Regression tests force a descending batch_get_item response order and a reversed fetch order, both verified to fail without the fix. Co-Authored-By: Claude Fable 5 * fix(cache): add tools + system cachePoints so message-level misses read the stable prefix CacheConfig(strategy="auto") places exactly one message-level cachePoint; when its lookup misses, nothing is read and the whole prefix re-writes at the cache-write premium. One proven miss mode is structural: Anthropic's cache lookback checks only ~20 content blocks behind the breakpoint, so a wide parallel tool fan-out (18 parallel calls = ~38 new blocks) pushes the previous checkpoint out of range — prod session aecd387d observed cacheRead=0 / cacheWrite=134k mid-turn (~$0.34). Now the request carries 3 of Bedrock's max-4 cachePoints: - toolConfig tail via cache_tools="default" - system tail via SystemContentBlock list with trailing cachePoint (built in AgentFactory; the cache_prompt config key is deprecated) - last-user-message point via the existing auto strategy (which strips only message-level points, never system/tools ones) Both new points are gated on ModelConfig.bedrock_cache_points_supported() (mirrors Strands' _cache_strategy predicate) because unlike auto mode they would be sent verbatim to non-Anthropic models and rejected. Verified live on global.anthropic.claude-sonnet-5 via ConverseStream: simulated message-level miss reads the 6.5k-token system+tools prefix from cache (cacheRead=6497, cacheWrite=83) instead of re-writing it. CountTokens accepts the cachePoint-bearing request, so context attribution is unaffected. Position/budget test asserts exactly 3 cachePoints. Upstream: strands-agents/harness-sdk#3348 proposes auto mode keep a rolling pair of message cachePoints so fan-outs stay within the lookback. Co-Authored-By: Claude Fable 5 * feat(observability): make prompt-cache economics measurable per model call A $1.60 prod conversation audit (session aecd387d) showed 75% of spend was avoidable Bedrock prompt-cache re-writes, and diagnosing the causes took hours of manual forensics against raw DynamoDB rows. This makes the whole class measurable end to end: - PrefixFingerprintHook (BeforeModelCallEvent) hashes the three cacheable prefix components per model call — toolConfig (order-sensitive canonical JSON), effective system prompt (captured after AgentSkills injection), and message history excluding the newest message. The stream coordinator persists entry N on the turn's Nth assistant-message cost row, so a miss is diagnosable with a column diff instead of row forensics. - Write-time cacheStatus per cost row (first_write | hit | miss_ttl_expired | miss_avoidable | uncached) derived from the session's previous C# row (one GSI read), plus wastedUsd for avoidable misses priced at the cache-write premium over cache-read from the row's own pricingSnapshot. Turn rows now write sequentially (was parallel) so each call classifies against its true predecessor. - Session-row rollups next to totalCost: totalCacheReadTokens, totalCacheWriteTokens, avoidableMissCount, wastedUsd — cache-efficiency ratio for lists/admin without scanning cost rows. - Admin cost anatomy: GET /admin/costs/sessions/{sessionId}/calls (require_admin) returns chronological per-call rows with token splits, cost, cacheStatus, and fingerprints, plus session-level cache summary. - CloudWatch EMF per call (CacheReadTokens / CacheWriteTokens / AvoidableMiss / WastedUsd) via a raw-JSON stdout logger — dashboard and alarm on fleet cache-write share with no SDK calls or extra IAM. - CI determinism guard: builds the chat-agent surface twice from shuffled skill/role/tool record orders (RBAC merge -> skills runtime -> Agent -> AgentSkills injection) and asserts identical system-prompt and toolConfig fingerprints; verified to fail when the skill-ordering sort is removed. Complements the sorting + byte-stability fixes from feature/prompt-cache-stability (merged in). Co-Authored-By: Claude Fable 5 * feat(observability): kill switch + prompt-cache conventions in CLAUDE.md PROMPT_CACHE_OBSERVABILITY_ENABLED=false disables the fingerprint hook, per-call cacheStatus derivation (and its GSI read), and EMF emission — default ON per house convention; empty string stays enabled. Raw cacheRead/cacheWrite token rollups are unaffected (usage passthrough, not derived). CLAUDE.md now encodes the determinism + byte-stability contract and the fingerprint-based debugging recipe. Co-Authored-By: Claude Fable 5 * chore: PR #697 follow-up breadcrumbs + token-cost-effectiveness tenet - CLAUDE.md: add "token cost effectiveness is a design tenet" bullet under Key Conventions — prefix determinism / bounded per-turn payloads / verify via the #697 observability, balanced so quality wins on genuine conflict. - kaizen review-queue: queue the two deferred #697 follow-ups — track harness-sdk#3348 (rolling-pair cachePoints; local workaround gated on dashboard evidence) and the ContextOffloader S3 adoption spike (with its four known gotchas). Co-Authored-By: Claude Fable 5 * feat(observability): prompt-cache CloudWatch dashboard + alarms (PR #697 follow-up) New cross-service construct area lib/constructs/observability/ with a PromptCacheObservabilityConstruct composed into PlatformStack. Graphs the dimension-less EMF metrics both APIs emit into AgentCoreStack/PromptCache (cache read/write tokens, a cache-efficiency MathExpression, AvoidableMiss, WastedUsd) plus a Logs Insights widget over the runtime log group grouped by cacheStatus. Console-only alarms on AvoidableMiss and WastedUsd Sums (stricter in prod, NOT_BREACHING on missing data so the PROMPT_CACHE_OBSERVABILITY_ENABLED kill switch stays quiet). No SNS — alerting infra is deliberately out of scope, matching kb-sync and scheduled-runs. Co-Authored-By: Claude Fable 5 * feat(admin-costs): add per-session cost-anatomy drill-down page Consumes GET /admin/costs/sessions/{id}/calls (backend PR #697): - SessionCostAnatomy / SessionCallRow / PrefixFingerprints models + CacheStatus union - AdminCostHttpService.getSessionCostAnatomy with URL-encoded session id - New /admin/costs/sessions/:id route (lazy, component input binding) - Drill-down page: summary rollups (total cost, cache efficiency incl. null, avoidable misses, wasted USD, cache read/write tokens), chronological calls table with color-coded cacheStatus badges, and prefix-fingerprint diffing that flags which hash (tools/system/history) flipped vs the previous fingerprinted call — the cache-buster diagnosis on miss_avoidable rows. Expandable rows show full hashes + messageCount; 404 renders a no-cost-rows empty state. - Session-id lookup form on the Cost Analytics dashboard as the entry point - Vitest specs for the diff util, HTTP method, and page states Co-Authored-By: Claude Fable 5 * fix(observability): don't flag first cache write after below-threshold calls as miss_avoidable When every prior call in a session was uncached (prompt below the model's minimum cacheable prefix, e.g. ~4096 tokens on Claude Haiku 4.5), the first call that crosses the threshold does cacheWrite>0/cacheRead=0 and was classified miss_avoidable — inflating the AvoidableMiss and WastedUsd EMF metrics and the admin Session Cost Anatomy page. Verified live in dev-ai session 9a1f25b2 (calls 1-5 uncached at 3.5-4k tokens, call 6 falsely flagged with write=4122/read=0). classify_cache_status now takes the previous call's cached-prefix token total: when the immediately preceding call had zero cache activity there was no entry to read from, so the write is classified first_write (the expected initial population) and excluded from waste pricing. Unknown (None) keeps the previous behavior. Co-Authored-By: Claude Fable 5 * Set S3_USER_FILES_BUCKET_NAME on inference-api runtime The AgentCore Runtime env block set DYNAMODB_USER_FILES_TABLE_NAME but not S3_USER_FILES_BUCKET_NAME, so the Word-document tools' _user_files_bucket() fell back to the literal 'user-files' default and PutObject failed with AccessDenied (and earlier PermanentRedirect against that unrelated bucket). The runtime role's UserFilesBucketAccess already grants Get/Put/Delete/List on the real bucket; this just points the runtime at it. tsc build passes. * Fail loudly when Word doc storage bucket is unconfigured _user_files_bucket() previously defaulted to a literal 'user-files' bucket when S3_USER_FILES_BUCKET_NAME was unset, which surfaced a missing runtime env var as a confusing S3 PermanentRedirect/AccessDenied. It now raises _StorageNotConfiguredError, and create/modify/read short-circuit before the Code Interpreter run with a clear 'storage is not configured' message. * feat(office-tools): add Excel spreadsheet creation and editing tools - Add excel_spreadsheet_tool.py with create/modify/list/read tools for .xlsx files - Create office/_storage.py module with shared Code Interpreter and S3 storage utilities for Word and Excel - Implement Excel toolset integration in inference_api chat routes with tool injection - Add "Excel Spreadsheets" catalog entry to DEFAULT_TOOLS in seed_bootstrap_data.py - Replace word-document-renderer with generic file-download-renderer for all generated office documents - Extend Word document tool to use shared office storage module - Update test fixtures and chat routes to support Excel tool provisioning - Generated Excel files are persisted to S3_USER_FILES_BUCKET_NAME and appear in chat Files panel * fix(infra): allow the mcp-sandbox origin in the SPA frame-src CSP The SPA distribution's CloudFront ResponseHeadersPolicy only ever opened frame-src to 'self' and the artifacts origin, so every domained deploy CSP-blocked the MCP App sandbox iframe (mcp-sandbox.{domain}) — the sandbox side's frame-ancestors was locked to the SPA origin from the start, but the SPA side was never extended. Localhost dev bypasses CloudFront's response headers entirely, which masked the gap through live verification. Thread the already-computed mcpSandboxProxyOrigin from PlatformStack into SpaDistributionConstruct and append it to frame-src. Add a regression test that synths a domained PlatformStack and asserts both iframe origins are present in the frontend headers policy. Co-Authored-By: Claude Fable 5 * Release/1.10.0 Feature release adding Excel spreadsheet creation and editing to chat, plus the CSP fix that unblocks MCP App iframes on deployed environments. - Excel spreadsheet toolset (create/modify/list/read .xlsx via openpyxl in Code Interpreter) behind a single "Excel Spreadsheets" catalog toggle; files land in the chat Files panel (#709) - Shared office/_storage.py module for Word + Excel; generic file-download renderer replaces the Word-specific one (#709) - SPA CloudFront CSP frame-src now includes the mcp-sandbox origin, fixing MCP App UIs blocked on every domained deploy (#714) - CDK deploy required: SPA ResponseHeadersPolicy update via platform.yml Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> Co-authored-by: derrickfink Co-authored-by: Derrick Fink Co-authored-by: Roman Meredith Co-authored-by: Roman meredith <48036775+ramenNoodles1998@users.noreply.github.com> --- CHANGELOG.md | 16 + README.md | 4 +- RELEASE_NOTES.md | 42 ++ VERSION | 2 +- backend/pyproject.toml | 2 +- backend/scripts/seed_bootstrap_data.py | 17 + .../builtin_tools/excel_spreadsheet_tool.py | 532 ++++++++++++++++++ .../agents/builtin_tools/office/__init__.py | 6 + .../agents/builtin_tools/office/_storage.py | 369 ++++++++++++ .../builtin_tools/word_document_tool.py | 363 +----------- backend/src/apis/inference_api/chat/routes.py | 48 ++ backend/tests/test_seed_system_admin_jwt.py | 20 +- backend/uv.lock | 2 +- frontend/ai.client/package-lock.json | 4 +- frontend/ai.client/package.json | 2 +- .../inline-visual/inline-visual.component.ts | 11 +- .../file-download-renderer.component.ts | 140 +++++ .../word-document-renderer.component.ts | 96 ---- .../spa/spa-distribution-construct.ts | 17 +- infrastructure/lib/platform-stack.ts | 1 + infrastructure/package-lock.json | 4 +- infrastructure/package.json | 2 +- infrastructure/test/spa-frame-src-csp.test.ts | 88 +++ .../test/transport-security.test.ts | 1 + 24 files changed, 1338 insertions(+), 451 deletions(-) create mode 100644 backend/src/agents/builtin_tools/excel_spreadsheet_tool.py create mode 100644 backend/src/agents/builtin_tools/office/__init__.py create mode 100644 backend/src/agents/builtin_tools/office/_storage.py create mode 100644 frontend/ai.client/src/app/session/components/message-list/components/inline-visual/renderers/file-download-renderer.component.ts delete mode 100644 frontend/ai.client/src/app/session/components/message-list/components/inline-visual/renderers/word-document-renderer.component.ts create mode 100644 infrastructure/test/spa-frame-src-csp.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c6b30c72f..6a8a39eaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to this project are documented in this file. Format follows For narrative release notes written for operators and product owners, see [RELEASE_NOTES.md](RELEASE_NOTES.md). +## [1.10.0] - 2026-07-21 + +Feature release adding **Excel spreadsheet creation and editing to chat** — a four-tool `.xlsx` toolset behind a single "Excel Spreadsheets" catalog toggle, built on a new shared office-document storage module — and fixing the CSP gap that **blocked every MCP App iframe on deployed environments**. Requires a CDK deploy (SPA CloudFront response-headers change); no data migration. + +### 🚀 Added + +- Excel spreadsheet toolset — `create_excel_spreadsheet`, `modify_excel_spreadsheet`, `list_excel_spreadsheets`, `read_excel_spreadsheet` build and edit `.xlsx` files via openpyxl in the sandboxed Code Interpreter; generated files persist to the user-files S3 bucket and appear in the chat Files panel with a download link. One catalog entry ("Excel Spreadsheets", gate key `create_excel_spreadsheet`, off by default) provisions the whole set (#709) + +### ✨ Improved + +- Word-document tools refactored onto the new shared `office/_storage.py` module (Code Interpreter + S3 persistence now common to Word and Excel), and all generated office documents render through a generic file-download renderer that replaces the Word-specific one (#709) + +### 🐛 Fixed + +- MCP App UIs now render on deployed environments — the SPA distribution's CSP `frame-src` never included the `mcp-sandbox.{domain}` origin, so every domained deploy blocked the App iframe with a CSP violation (localhost bypasses CloudFront's headers, which masked it). `PlatformStack` now threads the sandbox proxy origin into `SpaDistributionConstruct`, with a synth-time regression test (#714) + ## [1.9.0] - 2026-07-20 Feature release making **Bedrock prompt-cache economics stable and measurable**. Three cache-busting defects in the model-call path are fixed (per-turn history mutation, nondeterministic skill ordering, single-cachePoint fragility), and a new observability layer makes every model call's cache behavior diagnosable: prefix fingerprints and a `cacheStatus` classification on each cost row, an admin Session Cost Anatomy drill-down page, CloudWatch EMF metrics, and a dashboard with alarms. Also fixes chat-input textarea sizing and points the deployed runtime's Word-document tools at the real user-files bucket. Requires a CDK deploy (new dashboard construct + one runtime env var); no data migration. diff --git a/README.md b/README.md index c014c9a75..9742c0d8f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ **An open-source, production-ready Generative AI platform for institutions** *Built by Boise State University, designed for everyone.* -[![Release](https://img.shields.io/badge/Release-v1.9.0-6366f1?style=flat&logo=github&logoColor=white)](RELEASE_NOTES.md) +[![Release](https://img.shields.io/badge/Release-v1.10.0-6366f1?style=flat&logo=github&logoColor=white)](RELEASE_NOTES.md) [![Nightly](https://github.com/Boise-State-Development/agentcore-public-stack/actions/workflows/nightly.yml/badge.svg)](https://github.com/Boise-State-Development/agentcore-public-stack/actions/workflows/nightly.yml) ![Python](https://img.shields.io/badge/Python-3.13+-3776AB?style=flat&logo=python&logoColor=white) @@ -296,7 +296,7 @@ agentcore-public-stack/ See [RELEASE_NOTES.md](RELEASE_NOTES.md) for the full changelog, including new features, bug fixes, platform upgrades, and deployment notes for each release. -**Current release:** v1.9.0 +**Current release:** v1.10.0 --- diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index dd55d3682..69e3b73a4 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,45 @@ +# Release Notes — v1.10.0 + +**Release Date:** July 21, 2026 +**Previous Release:** v1.9.0 (July 20, 2026) + +--- + +> 🏗️ **CDK deploy required this release** — the SPA CloudFront distribution's response-headers policy changes (MCP Apps CSP fix). No new AWS resources, no data migration, no dependency changes. Standard order: `platform.yml` → `backend.yml` → `frontend-deploy.yml`. + +--- + +## Highlights + +v1.10.0 brings **Excel spreadsheets to chat**: the agent can now create, edit, read, and list real `.xlsx` workbooks — built with openpyxl inside the sandboxed Code Interpreter and delivered through the chat Files panel with a download link — governed by a single "Excel Spreadsheets" catalog toggle. The same work extracts a shared office-document storage module that the Word tools now ride on. The release also fixes a day-one CSP gap that **blocked every MCP App iframe on deployed environments**: the SPA's `frame-src` never allowed the `mcp-sandbox` origin, and localhost testing (which bypasses CloudFront's headers) had masked it since the feature shipped. + +## Excel spreadsheets in chat + +Users can ask the agent to build or revise real Excel workbooks mid-conversation — budget templates, rosters, data exports — and get a downloadable `.xlsx` back in the chat's Files panel. + +### Backend + +- `agents/builtin_tools/excel_spreadsheet_tool.py` (530+ lines) — four tools: `create_excel_spreadsheet`, `modify_excel_spreadsheet`, `list_excel_spreadsheets`, `read_excel_spreadsheet`. Generation and edits run openpyxl inside the sandboxed AgentCore Code Interpreter; nothing executes in the API container. +- `agents/builtin_tools/office/_storage.py` — new shared storage module (Code Interpreter execution + S3 persistence) common to Word and Excel; `word_document_tool.py` is refactored onto it, dropping ~270 lines of duplicated plumbing. +- `apis/inference_api/chat/routes.py` — the toolset is injected at runtime when the catalog toggle is enabled. One catalog entry ("Excel Spreadsheets", gate key `create_excel_spreadsheet`, `enabledByDefault: false`) provisions all four tools; it is distinct from the spreadsheet *analysis* tools (`list_spreadsheets`/`analyze_spreadsheet`), which read uploaded tabular files. +- Generated files persist to the user-files bucket (`S3_USER_FILES_BUCKET_NAME`) and surface in the session's Files panel. + +### Frontend + +- New generic `file-download-renderer` component replaces the Word-specific `word-document-renderer` — all generated office documents (Word and Excel) now share one inline card with filename, type, and download link. + +## 🐛 Bug fixes + +- **MCP App UIs were blank on every deployed environment** — demoing an MCP App (e.g. Excalidraw) on a domained deploy failed with `Framing 'https://mcp-sandbox.{domain}/' violates the Content Security Policy directive: "frame-src 'self' https://artifacts.{domain}"`. Root cause: the MCP Apps rollout wired the *inbound* direction (the sandbox proxy's `frame-ancestors` is locked to the SPA origin) but never extended the SPA's own *outbound* `frame-src`, and all live verification ran on localhost:4200, which bypasses CloudFront's response headers. `PlatformStack` now threads the sandbox proxy origin (`https://mcp-sandbox.{domain}`) into `SpaDistributionConstruct` as a required prop, and a new synth-time test (`infrastructure/test/spa-frame-src-csp.test.ts`) asserts both iframe origins are present in the frontend headers policy so the gap can't silently reopen (#714) + +## 🚀 Deployment notes + +- **Run `platform.yml`** — the SPA distribution's `ResponseHeadersPolicy` changes (CSP `frame-src` gains the `mcp-sandbox.{domain}` origin). Quick, low-risk CloudFront-only update; then `backend.yml` and `frontend-deploy.yml` as usual. +- **Enable the Excel tool per environment** — the "Excel Spreadsheets" catalog entry ships in the bootstrap seed data with `enabledByDefault: false`. Environments seeded before this release won't have the row: add it via the admin Tools page (or re-run the tools seeding) and grant it to the appropriate roles via RBAC. +- The MCP Apps fix needs no configuration — environments where `mcp-sandbox.{domain}` is deployed start working as soon as the new headers policy is live (a hard refresh may be needed to drop the cached CSP). + +--- + # Release Notes — v1.9.0 **Release Date:** July 20, 2026 diff --git a/VERSION b/VERSION index f8e233b27..81c871de4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.9.0 +1.10.0 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5ccd017eb..f153f28a4 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agentcore-stack" -version = "1.9.0" +version = "1.10.0" requires-python = ">=3.10" description = "Multi-agent conversational AI system with AWS Bedrock AgentCore" readme = "README.md" diff --git a/backend/scripts/seed_bootstrap_data.py b/backend/scripts/seed_bootstrap_data.py index 41be7954b..26f9fb06c 100644 --- a/backend/scripts/seed_bootstrap_data.py +++ b/backend/scripts/seed_bootstrap_data.py @@ -442,6 +442,23 @@ def seed_default_models( "isPublic": True, "forwardAuthToken": False, }, + { + # Single catalog entry / toggle that provisions the whole Excel + # spreadsheet toolset. Enabling this one id injects create/modify/list/ + # read at runtime — see EXCEL_SPREADSHEET_TOOL_IDS and + # _build_excel_spreadsheet_tools in apis/inference_api/chat/routes.py. + # Keep the toolId as "create_excel_spreadsheet": it is the gate key. + # Distinct from the spreadsheet *analysis* tools (list_spreadsheets / + # analyze_spreadsheet), which read uploaded tabular files. + "toolId": "create_excel_spreadsheet", + "displayName": "Excel Spreadsheets", + "description": "Create, edit, read, and list Excel (.xlsx) spreadsheets using openpyxl in a sandboxed environment. Generated files are saved to the chat's Files with a download link.", + "category": "document", + "protocol": "local", + "enabledByDefault": False, + "isPublic": True, + "forwardAuthToken": False, + }, ] diff --git a/backend/src/agents/builtin_tools/excel_spreadsheet_tool.py b/backend/src/agents/builtin_tools/excel_spreadsheet_tool.py new file mode 100644 index 000000000..77f8e416f --- /dev/null +++ b/backend/src/agents/builtin_tools/excel_spreadsheet_tool.py @@ -0,0 +1,532 @@ +"""Excel spreadsheet tools (create / modify / list / read). + +Each tool runs openpyxl code inside AWS Bedrock Code Interpreter and uses the +existing user-files store (``apis.shared.files``) for persistence and delivery +— generated/modified ``.xlsx`` files land in ``S3_USER_FILES_BUCKET_NAME`` with +a ``FileMetadata`` row (status READY) in ``DYNAMODB_USER_FILES_TABLE_NAME``, so +they appear in the chat's Files panel and are downloadable via the app-api +``/files/{id}/preview-url`` route. + +Tools +----- +* ``create_excel_spreadsheet`` — build a new workbook from openpyxl code. +* ``modify_excel_spreadsheet`` — edit an existing workbook with openpyxl code. +* ``list_excel_spreadsheets`` — list the .xlsx files available in this chat. +* ``read_excel_spreadsheet`` — extract an existing workbook's cell values. + +This is the create/modify/read/list toolset for *generated* ``.xlsx`` files. It +is distinct from the spreadsheet *analysis* tools (``list_spreadsheets`` / +``analyze_spreadsheet`` in ``builtin_tools.spreadsheet_analysis``), which read +and aggregate existing uploaded/knowledge-base tabular files with pandas. + +Design notes +------------ +* The Code Interpreter + user-files storage plumbing is shared with the Word + toolset and lives in ``builtin_tools.office._storage``; this module keeps + only the openpyxl specifics (preamble, generate/modify/extract) and the four + tool factories. +* Identity (``user_id`` / ``session_id``) is captured by closure via the + ``make_*`` factories — the same pattern used by the artifacts, Word document, + and spreadsheet_analysis tools (the Strands runtime here does NOT populate + ``ToolContext.invocation_state`` with identity). The tools are injected + per-request through ``extra_tools`` (see ``_build_excel_spreadsheet_tools`` in + ``apis/inference_api/chat/routes.py``); they are deliberately NOT registered + in ``builtin_tools/__init__`` because they need request-scoped identity. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Dict, Optional + +from strands import tool + +from agents.builtin_tools.office._storage import ( + _DocGenError, + _ci_exec, + _ci_read_bytes, + _ci_write_bytes, + _download_card, + _download_s3_bytes, + _error, + _get_code_interpreter_id, + _NO_CI_MESSAGE, + _region, + _storage_configured, + _store_document, + _validate_document_name, +) + +logger = logging.getLogger(__name__) + +# Excel workbook MIME type (matches apis.shared.files.ALLOWED_MIME_TYPES). +_XLSX_MIME = ( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +) + +# Sandbox path used to stage a source workbook loaded from S3. +_SANDBOX_SOURCE = "_source.xlsx" + +_NO_STORAGE_MESSAGE = ( + "❌ Excel spreadsheet storage is not configured " + "(S3_USER_FILES_BUCKET_NAME is not set on the runtime)." +) + + +# --------------------------------------------------------------------------- +# openpyxl workbook builders (run in Code Interpreter) +# --------------------------------------------------------------------------- + + +_XLSX_PREAMBLE = ( + "from openpyxl import Workbook, load_workbook\n" + "from openpyxl.styles import Font, PatternFill, Alignment, Border, Side\n" + "from openpyxl.utils import get_column_letter\n" + "from openpyxl.chart import BarChart, LineChart, PieChart, Reference\n" +) + + +def _generate_xlsx_bytes( + code_interpreter_id: str, python_code: str, filename: str +) -> bytes: + """Build a new .xlsx from user code and return its bytes. + + Blocking (boto3 / Code Interpreter) — call via ``asyncio.to_thread``. + """ + from bedrock_agentcore.tools.code_interpreter_client import CodeInterpreter + + code_interpreter = CodeInterpreter(_region()) + code_interpreter.start(identifier=code_interpreter_id) + try: + # The user's code operates on a pre-initialized ``wb`` / ``ws`` and must + # not call Workbook()/wb.save() itself — we own the lifecycle. + _ci_exec( + code_interpreter, + ( + f"{_XLSX_PREAMBLE}\n" + "wb = Workbook()\n" + "ws = wb.active\n\n" + f"{python_code}\n\n" + f"wb.save({filename!r})\n" + ), + ) + data = _ci_read_bytes(code_interpreter, filename) + if data is None: + raise _DocGenError( + f"Workbook '{filename}' was not produced. Make sure your code " + "writes cells to `ws` (or another sheet on `wb`)." + ) + return data + finally: + try: + code_interpreter.stop() + except Exception: # pragma: no cover - cleanup best-effort + pass + + +def _modify_xlsx_bytes( + code_interpreter_id: str, + source_bytes: bytes, + python_code: str, + output_filename: str, +) -> bytes: + """Load an existing .xlsx, apply user edits, return the new bytes. + + Blocking — call via ``asyncio.to_thread``. + """ + from bedrock_agentcore.tools.code_interpreter_client import CodeInterpreter + + code_interpreter = CodeInterpreter(_region()) + code_interpreter.start(identifier=code_interpreter_id) + try: + _ci_write_bytes(code_interpreter, _SANDBOX_SOURCE, source_bytes) + _ci_exec( + code_interpreter, + ( + f"{_XLSX_PREAMBLE}\n" + f"wb = load_workbook({_SANDBOX_SOURCE!r})\n" + "ws = wb.active\n\n" + f"{python_code}\n\n" + f"wb.save({output_filename!r})\n" + ), + ) + data = _ci_read_bytes(code_interpreter, output_filename) + if data is None: + raise _DocGenError( + f"Modified workbook '{output_filename}' was not produced." + ) + return data + finally: + try: + code_interpreter.stop() + except Exception: # pragma: no cover - cleanup best-effort + pass + + +def _extract_xlsx_text(code_interpreter_id: str, source_bytes: bytes) -> str: + """Extract readable cell values (per sheet, pipe-delimited rows) from a .xlsx. + + Uses ``data_only=True`` so cached formula results are shown when present. + Blocking — call via ``asyncio.to_thread``. + """ + from bedrock_agentcore.tools.code_interpreter_client import CodeInterpreter + + code_interpreter = CodeInterpreter(_region()) + code_interpreter.start(identifier=code_interpreter_id) + try: + _ci_write_bytes(code_interpreter, _SANDBOX_SOURCE, source_bytes) + extraction = ( + "from openpyxl import load_workbook\n" + f"wb = load_workbook({_SANDBOX_SOURCE!r}, data_only=True)\n" + "lines = []\n" + "for ws in wb.worksheets:\n" + " lines.append('## Sheet: ' + str(ws.title))\n" + " empty = True\n" + " for row in ws.iter_rows(values_only=True):\n" + " cells = ['' if c is None else str(c) for c in row]\n" + " while cells and cells[-1] == '':\n" + " cells.pop()\n" + " if not cells:\n" + " continue\n" + " empty = False\n" + " lines.append(' | '.join(cells))\n" + " if empty:\n" + " lines.append('(empty sheet)')\n" + " lines.append('')\n" + "print('\\n'.join(lines))\n" + ) + return _ci_exec(code_interpreter, extraction).strip() + finally: + try: + code_interpreter.stop() + except Exception: # pragma: no cover - cleanup best-effort + pass + + +# --------------------------------------------------------------------------- +# User-files lookup +# --------------------------------------------------------------------------- + + +async def _find_excel_spreadsheet( + user_id: str, session_id: str, spreadsheet_name: str +): + """Find the newest READY .xlsx in this session matching ``spreadsheet_name``. + + Returns the ``FileMetadata`` or ``None``. ``list_session_files`` returns + newest-first, so the first match is the latest version. + """ + from apis.shared.files import FileStatus, get_file_upload_repository + + target = ( + spreadsheet_name + if spreadsheet_name.lower().endswith(".xlsx") + else f"{spreadsheet_name}.xlsx" + ) + files = await get_file_upload_repository().list_session_files( + session_id, status=FileStatus.READY + ) + for meta in files: + if ( + meta.user_id == user_id + and meta.mime_type == _XLSX_MIME + and meta.filename.lower() == target.lower() + ): + return meta + return None + + +# --------------------------------------------------------------------------- +# Tool factories +# --------------------------------------------------------------------------- + + +def make_create_excel_spreadsheet_tool(session_id: str, user_id: str): + """Create a ``create_excel_spreadsheet`` tool bound to the given identity.""" + + @tool + async def create_excel_spreadsheet( + python_code: str, + spreadsheet_name: str, + ) -> Any: + """Create a new Excel (.xlsx) spreadsheet using openpyxl code. + + Executes openpyxl code in a sandboxed Code Interpreter to build a + workbook from scratch, saves it to the user's files, and returns a + download card. Great for tabular data, multi-sheet workbooks, formatted + headers, formulas, and native Excel charts. + + Available libraries in the sandbox: openpyxl, pandas, numpy. + + Args: + python_code: openpyxl code that builds the workbook. A blank + workbook is already available as ``wb = Workbook()`` with its + first sheet as ``ws = wb.active`` — do NOT call ``Workbook()`` + or ``wb.save()`` yourself; the tool saves it for you. + ``Workbook``, ``load_workbook``, ``Font``, ``PatternFill``, + ``Alignment``, ``Border``, ``Side``, ``get_column_letter`` and + the chart classes (``BarChart``, ``LineChart``, ``PieChart``, + ``Reference``) are already imported. + + Example (headers + rows + a formula): + ws.title = 'Sales' + ws.append(['Quarter', 'Revenue']) + ws['A1'].font = Font(bold=True) + ws['B1'].font = Font(bold=True) + ws.append(['Q1', 100]) + ws.append(['Q2', 120]) + ws['B4'] = '=SUM(B2:B3)' + + Example (add a second sheet + a bar chart): + ws2 = wb.create_sheet('Chart') + data = Reference(ws, min_col=2, min_row=1, max_row=3) + cats = Reference(ws, min_col=1, min_row=2, max_row=3) + chart = BarChart() + chart.add_data(data, titles_from_data=True) + chart.set_categories(cats) + ws2.add_chart(chart, 'A1') + + To load data from a pandas DataFrame: + from openpyxl.utils.dataframe import dataframe_to_rows + for r in dataframe_to_rows(df, index=False, header=True): + ws.append(r) + + spreadsheet_name: File name WITHOUT extension (.xlsx is added + automatically). Use only letters, numbers, hyphens, and + underscores (e.g. "sales-2026", "Q4_budget"). + + Returns: + An inline download card. The workbook is also saved to this + chat's Files. + """ + is_valid, error_msg = _validate_document_name(spreadsheet_name) + if not is_valid: + return _error( + f"❌ Invalid spreadsheet name '{spreadsheet_name}': {error_msg}\n\n" + "Examples: sales-2026, Q4_budget, report-final" + ) + + filename = f"{spreadsheet_name}.xlsx" + code_interpreter_id = _get_code_interpreter_id() + if not code_interpreter_id: + return _error(_NO_CI_MESSAGE) + if not _storage_configured(): + return _error(_NO_STORAGE_MESSAGE) + + try: + file_bytes = await asyncio.to_thread( + _generate_xlsx_bytes, code_interpreter_id, python_code, filename + ) + except _DocGenError as exc: + return _error( + f"❌ Failed to create '{filename}'.\n\n```\n{exc}\n```\n\n" + "Check the openpyxl code for errors." + ) + except Exception as exc: # noqa: BLE001 - surface any sandbox error + logger.error(f"create_excel_spreadsheet sandbox error: {exc}") + return _error(f"❌ Failed to create '{filename}': {exc}") + + try: + _id, download_url, size_kb = await _store_document( + user_id, session_id, filename, file_bytes, _XLSX_MIME + ) + except Exception as exc: # noqa: BLE001 - storage failure is terminal + logger.error(f"create_excel_spreadsheet storage error: {exc}") + return _error(f"❌ Created '{filename}' but failed to save it: {exc}") + + return _download_card(filename, download_url, size_kb, "Created") + + return create_excel_spreadsheet + + +def make_modify_excel_spreadsheet_tool(session_id: str, user_id: str): + """Create a ``modify_excel_spreadsheet`` tool bound to the given identity.""" + + @tool + async def modify_excel_spreadsheet( + spreadsheet_name: str, + python_code: str, + output_name: Optional[str] = None, + ) -> Any: + """Modify an existing Excel (.xlsx) spreadsheet with openpyxl code. + + Loads a workbook previously created in this chat, runs your openpyxl + code against it, and saves the result (as a new file so the original is + preserved). Returns a download card. + + Use ``list_excel_spreadsheets`` first if you are unsure of the exact + name. + + Args: + spreadsheet_name: Name of the existing workbook to edit (with or + without the .xlsx extension), e.g. "sales-2026". + python_code: openpyxl code that edits the workbook. The loaded + workbook is available as ``wb = load_workbook(...)`` and its + active sheet as ``ws = wb.active`` — do NOT call + ``load_workbook()`` or ``wb.save()`` yourself. Access other + sheets with ``wb['SheetName']`` and add sheets with + ``wb.create_sheet('Name')``. ``Font``, ``PatternFill``, + ``Alignment``, ``Border``, ``Side``, ``get_column_letter`` and + the chart classes are already imported. + + Example (append rows to the active sheet): + ws.append(['Q3', 140]) + ws.append(['Q4', 160]) + + output_name: Optional name (without extension) for the edited copy. + Defaults to the source name (a new versioned copy is saved). + + Returns: + An inline download card for the edited workbook. + """ + code_interpreter_id = _get_code_interpreter_id() + if not code_interpreter_id: + return _error(_NO_CI_MESSAGE) + if not _storage_configured(): + return _error(_NO_STORAGE_MESSAGE) + + source = await _find_excel_spreadsheet(user_id, session_id, spreadsheet_name) + if source is None: + return _error( + f"❌ No Excel spreadsheet named '{spreadsheet_name}' was found in " + "this chat. Use list_excel_spreadsheets to see what's available." + ) + + out_base = output_name or source.filename + if out_base.lower().endswith(".xlsx"): + out_base = out_base[: -len(".xlsx")] + is_valid, error_msg = _validate_document_name(out_base) + if not is_valid: + return _error( + f"❌ Invalid output name '{out_base}': {error_msg}" + ) + output_filename = f"{out_base}.xlsx" + + try: + source_bytes = await asyncio.to_thread( + _download_s3_bytes, source.s3_bucket, source.s3_key + ) + file_bytes = await asyncio.to_thread( + _modify_xlsx_bytes, + code_interpreter_id, + source_bytes, + python_code, + output_filename, + ) + except _DocGenError as exc: + return _error( + f"❌ Failed to modify '{source.filename}'.\n\n```\n{exc}\n```\n\n" + "Check the openpyxl code for errors." + ) + except Exception as exc: # noqa: BLE001 - surface any sandbox error + logger.error(f"modify_excel_spreadsheet error: {exc}") + return _error(f"❌ Failed to modify '{source.filename}': {exc}") + + try: + _id, download_url, size_kb = await _store_document( + user_id, session_id, output_filename, file_bytes, _XLSX_MIME + ) + except Exception as exc: # noqa: BLE001 - storage failure is terminal + logger.error(f"modify_excel_spreadsheet storage error: {exc}") + return _error( + f"❌ Modified '{source.filename}' but failed to save it: {exc}" + ) + + return _download_card(output_filename, download_url, size_kb, "Updated") + + return modify_excel_spreadsheet + + +def make_list_excel_spreadsheets_tool(session_id: str, user_id: str): + """Create a ``list_excel_spreadsheets`` tool bound to the given identity.""" + + @tool + async def list_excel_spreadsheets() -> Dict[str, Any]: + """List the Excel (.xlsx) spreadsheets available in this chat. + + Returns the file names and sizes of workbooks created or modified in + this conversation. Use the names with modify_excel_spreadsheet or + read_excel_spreadsheet. + """ + from apis.shared.files import FileStatus, get_file_upload_repository + + files = await get_file_upload_repository().list_session_files( + session_id, status=FileStatus.READY + ) + seen: set[str] = set() + rows = [] + for meta in files: # newest-first + if meta.user_id != user_id or meta.mime_type != _XLSX_MIME: + continue + if meta.filename in seen: + continue + seen.add(meta.filename) + rows.append(f"- {meta.filename} ({meta.size_bytes / 1024:.1f} KB)") + + if not rows: + text = ( + "No Excel spreadsheets in this chat yet. Use " + "create_excel_spreadsheet to make one." + ) + else: + text = "Excel spreadsheets in this chat:\n" + "\n".join(rows) + return {"content": [{"text": text}], "status": "success"} + + return list_excel_spreadsheets + + +def make_read_excel_spreadsheet_tool(session_id: str, user_id: str): + """Create a ``read_excel_spreadsheet`` tool bound to the given identity.""" + + @tool + async def read_excel_spreadsheet(spreadsheet_name: str) -> Dict[str, Any]: + """Read the cell values of an existing Excel (.xlsx) spreadsheet. + + Extracts each sheet's rows (pipe-delimited, cached formula values when + present) from a workbook created in this chat so you can reference or + summarize its contents. Use list_excel_spreadsheets first if unsure of + the exact name. + + Args: + spreadsheet_name: Name of the workbook to read (with or without the + .xlsx extension), e.g. "sales-2026". + + Returns: + The workbook's cell values, grouped by sheet. + """ + code_interpreter_id = _get_code_interpreter_id() + if not code_interpreter_id: + return _error(_NO_CI_MESSAGE) + if not _storage_configured(): + return _error(_NO_STORAGE_MESSAGE) + + source = await _find_excel_spreadsheet(user_id, session_id, spreadsheet_name) + if source is None: + return _error( + f"❌ No Excel spreadsheet named '{spreadsheet_name}' was found in " + "this chat. Use list_excel_spreadsheets to see what's available." + ) + + try: + source_bytes = await asyncio.to_thread( + _download_s3_bytes, source.s3_bucket, source.s3_key + ) + text = await asyncio.to_thread( + _extract_xlsx_text, code_interpreter_id, source_bytes + ) + except _DocGenError as exc: + return _error(f"❌ Failed to read '{source.filename}': {exc}") + except Exception as exc: # noqa: BLE001 - surface any sandbox error + logger.error(f"read_excel_spreadsheet error: {exc}") + return _error(f"❌ Failed to read '{source.filename}': {exc}") + + body = text or "(The workbook has no extractable cell values.)" + return { + "content": [ + {"text": f"Content of {source.filename}:\n\n{body}"} + ], + "status": "success", + } + + return read_excel_spreadsheet diff --git a/backend/src/agents/builtin_tools/office/__init__.py b/backend/src/agents/builtin_tools/office/__init__.py new file mode 100644 index 000000000..85f970bac --- /dev/null +++ b/backend/src/agents/builtin_tools/office/__init__.py @@ -0,0 +1,6 @@ +"""Office document tooling shared internals. + +Holds the Code Interpreter + user-files storage helpers shared by the Word +(``word_document_tool``) and Excel (``excel_spreadsheet_tool``) toolsets. See +``_storage`` for the details. +""" diff --git a/backend/src/agents/builtin_tools/office/_storage.py b/backend/src/agents/builtin_tools/office/_storage.py new file mode 100644 index 000000000..87c6b85f8 --- /dev/null +++ b/backend/src/agents/builtin_tools/office/_storage.py @@ -0,0 +1,369 @@ +"""Shared storage + Code Interpreter helpers for the office document tools. + +Both the Word (``word_document_tool``) and Excel (``excel_spreadsheet_tool``) +toolsets build a binary Office file inside AWS Bedrock Code Interpreter and +persist it to the existing user-files store (``apis.shared.files``): the file +lands in ``S3_USER_FILES_BUCKET_NAME`` with a ``FileMetadata`` row (status +READY) in ``DYNAMODB_USER_FILES_TABLE_NAME``, so it appears in the chat's Files +panel and is downloadable via the app-api ``/files/{id}/preview-url`` route. + +The two toolsets differ only in the document format (``.docx`` vs ``.xlsx``) +and the library used inside the sandbox (python-docx vs openpyxl); everything +about talking to Code Interpreter and the user-files store is identical, so it +lives here to avoid drift. + +Design notes +------------ +* Code Interpreter usage mirrors ``code_interpreter_diagram_tool.py`` — the + interpreter id is resolved from ``AGENTCORE_CODE_INTERPRETER_ID`` (or SSM), + a session is started with ``CodeInterpreter(region).start(identifier=...)``, + and always stopped in a ``finally`` block by the caller. +* Storage resolves the user-files bucket's real region via ``HeadBucket`` + (NOT ``AWS_REGION``, which the AgentCore Runtime does not reliably pin to the + bucket region) and does not hard-pin ``endpoint_url`` so botocore can still + auto-correct the region. ``S3_USER_FILES_BUCKET_NAME`` is required; the + helpers fail loudly when it is unset rather than targeting a bogus default + bucket the runtime has no access to. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import logging +import os +import re +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, Optional, Tuple + +import boto3 +from botocore.config import Config +from botocore.exceptions import ClientError + +logger = logging.getLogger(__name__) + +# Presigned download links are short-lived; long enough for the user to click. +_DOWNLOAD_URL_TTL = 60 * 60 # 1 hour + + +class _DocGenError(Exception): + """Raised when Code Interpreter fails to run the document code.""" + + +class _StorageNotConfiguredError(Exception): + """Raised when the user-files S3 bucket is not configured for the runtime.""" + + +def _region() -> str: + return ( + os.environ.get("AWS_REGION") + or os.environ.get("AWS_DEFAULT_REGION") + or "us-west-2" + ) + + +def _get_code_interpreter_id() -> Optional[str]: + """Resolve the Custom Code Interpreter id (env first, then SSM).""" + ci_id = os.getenv("AGENTCORE_CODE_INTERPRETER_ID") + if ci_id: + return ci_id + try: + project_name = os.getenv("PROJECT_NAME", "strands-agent-chatbot") + environment = os.getenv("ENVIRONMENT", "dev") + ssm = boto3.client("ssm", region_name=_region()) + resp = ssm.get_parameter( + Name=f"/{project_name}/{environment}/agentcore/code-interpreter-id" + ) + return resp["Parameter"]["Value"] + except Exception as exc: # pragma: no cover - best-effort fallback + logger.warning(f"Code Interpreter id not found in env or SSM: {exc}") + return None + + +def _validate_document_name(name: str) -> Tuple[bool, Optional[str]]: + """Validate a document name (without extension). + + Rules: letters, numbers, hyphens and underscores only; no spaces or other + special characters; no consecutive, leading, or trailing hyphens. + """ + if not name: + return False, "Document name cannot be empty" + + if not re.match(r"^[a-zA-Z0-9_\-]+$", name): + invalid = sorted(set(re.findall(r"[^a-zA-Z0-9_\-]", name))) + return ( + False, + f"Invalid characters in name: {invalid}. Use only letters, " + "numbers, hyphens, and underscores.", + ) + if "--" in name: + return False, "Name cannot contain consecutive hyphens (--)" + if name.startswith("-") or name.endswith("-"): + return False, "Name cannot start or end with a hyphen" + return True, None + + +_s3_client = None +_bucket_region: Optional[str] = None + + +def _resolve_bucket_region(bucket: str) -> str: + """Discover the user-files bucket's real region. + + The AgentCore Runtime's ``AWS_REGION`` does not reliably match the + deployment/bucket region. Pinning the S3 client to the wrong region makes + ``PutObject`` fail with ``PermanentRedirect``. ``HeadBucket`` (maps to + ``s3:ListBucket``, which the runtime role has) returns the true region in + the ``x-amz-bucket-region`` header — on a 200 when probed from the matching + region and on the 301 otherwise. This avoids depending on + ``s3:GetBucketLocation``, which the inference-api role is not granted. Falls + back to the env region if the lookup is unavailable — ``PutObject`` still + succeeds in that case because the client below no longer hard-pins + ``endpoint_url``, so botocore's built-in S3 region redirect can correct it. + """ + global _bucket_region + if _bucket_region: + return _bucket_region + region = None + try: + probe = boto3.client("s3", region_name="us-east-1") + resp = probe.head_bucket(Bucket=bucket) + region = ( + resp.get("ResponseMetadata", {}) + .get("HTTPHeaders", {}) + .get("x-amz-bucket-region") + ) + except ClientError as exc: + region = ( + exc.response.get("ResponseMetadata", {}) + .get("HTTPHeaders", {}) + .get("x-amz-bucket-region") + ) + if not region: + logger.warning(f"Could not resolve region for bucket {bucket}: {exc}") + except Exception as exc: # pragma: no cover - fall back to env region + logger.warning(f"Could not resolve region for bucket {bucket}: {exc}") + _bucket_region = region or _region() + return _bucket_region + + +def _s3(): + """SigV4 S3 client pinned to the user-files bucket's actual region. + + Uses the bucket's real region (not ``AWS_REGION``) so ``PutObject`` never + hits ``PermanentRedirect`` in the AgentCore Runtime. No explicit + ``endpoint_url``: botocore then builds the correct regional virtual-host + endpoint (which keeps presigned download URLs CORS-safe) and can still + auto-correct the region if the resolved value is off. + """ + global _s3_client + if _s3_client is None: + region = _resolve_bucket_region(_user_files_bucket()) + _s3_client = boto3.client( + "s3", + region_name=region, + config=Config( + signature_version="s3v4", + s3={"addressing_style": "virtual"}, + ), + ) + return _s3_client + + +def _user_files_bucket() -> str: + # Fail loudly rather than silently targeting a literal "user-files" bucket + # the runtime has no access to. That default misreported a missing env var + # as an S3 PermanentRedirect / AccessDenied and cost real debugging time. + # The runtime env is wired in infrastructure's + # inference-agentcore-construct.ts (S3_USER_FILES_BUCKET_NAME). + bucket = os.environ.get("S3_USER_FILES_BUCKET_NAME") + if not bucket: + raise _StorageNotConfiguredError( + "S3_USER_FILES_BUCKET_NAME is not set; the runtime cannot store or " + "retrieve generated documents." + ) + return bucket + + +def _storage_configured() -> bool: + """True when the user-files bucket env var is set.""" + return bool(os.environ.get("S3_USER_FILES_BUCKET_NAME")) + + +# --------------------------------------------------------------------------- +# Code Interpreter primitives +# --------------------------------------------------------------------------- + + +def _ci_exec(code_interpreter, code: str) -> str: + """Run Python in the sandbox; return stdout or raise _DocGenError.""" + response = code_interpreter.invoke( + "executeCode", + {"code": code, "language": "python", "clearContext": False}, + ) + stdout = "" + for event in response.get("stream", []): + result = event.get("result", {}) + if result.get("isError", False): + stderr = result.get("structuredContent", {}).get( + "stderr", "Unknown error" + ) + logger.error(f"Code Interpreter error: {stderr[:500]}") + raise _DocGenError(stderr[:1000]) + out = result.get("structuredContent", {}).get("stdout", "") + if out: + stdout += out + return stdout + + +def _ci_read_bytes(code_interpreter, filename: str) -> Optional[bytes]: + """Read a file out of the sandbox as bytes (or None if missing).""" + download = code_interpreter.invoke("readFiles", {"paths": [filename]}) + content = None + for event in download.get("stream", []): + result = event.get("result", {}) + for block in result.get("content", []) or []: + if "data" in block: + content = block["data"] + elif "resource" in block and "blob" in block["resource"]: + content = block["resource"]["blob"] + if content: + break + if content: + break + if content is None: + return None + # Code Interpreter may hand back raw bytes or a base64 string. + if isinstance(content, str): + content = base64.b64decode(content) + return content + + +def _ci_write_bytes(code_interpreter, path: str, data: bytes) -> None: + """Write binary bytes into the sandbox (base64 text + decode in-sandbox).""" + b64 = base64.b64encode(data).decode("ascii") + code_interpreter.invoke( + "writeFiles", + {"content": [{"path": f"{path}.b64", "text": b64}]}, + ) + _ci_exec( + code_interpreter, + ( + "import base64\n" + f"with open({path + '.b64'!r}) as _f:\n" + " _raw = base64.b64decode(_f.read())\n" + f"with open({path!r}, 'wb') as _o:\n" + " _o.write(_raw)\n" + ), + ) + + +# --------------------------------------------------------------------------- +# User-files store helpers +# --------------------------------------------------------------------------- + + +def _download_s3_bytes(bucket: str, key: str) -> bytes: + """Read an object's bytes from S3 (blocking — use ``asyncio.to_thread``).""" + resp = _s3().get_object(Bucket=bucket, Key=key) + return resp["Body"].read() + + +async def _store_document( + user_id: str, + session_id: str, + filename: str, + file_bytes: bytes, + mime_type: str, +) -> Tuple[str, str, str]: + """Persist a generated file to the user-files store and mint a download URL. + + Returns ``(upload_id, download_url, size_kb)``. ``mime_type`` is stored on + the ``FileMetadata`` row and used for the download ``Content-Type``. + """ + from apis.shared.files import ( + FileMetadata, + FileStatus, + get_file_upload_repository, + ) + + bucket = _user_files_bucket() + timestamp_hex = format( + int(datetime.now(timezone.utc).timestamp() * 1000), "x" + ) + upload_id = f"{timestamp_hex}_{uuid.uuid4().hex[:16]}" + s3_key = f"user-files/{user_id}/{session_id}/{upload_id}/{filename}" + + await asyncio.to_thread( + _s3().put_object, + Bucket=bucket, + Key=s3_key, + Body=file_bytes, + ContentType=mime_type, + ) + + metadata = FileMetadata( + upload_id=upload_id, + user_id=user_id, + session_id=session_id, + filename=filename, + mime_type=mime_type, + size_bytes=len(file_bytes), + s3_key=s3_key, + s3_bucket=bucket, + status=FileStatus.READY, + ) + await get_file_upload_repository().create_file(metadata) + + download_url = await asyncio.to_thread( + _s3().generate_presigned_url, + "get_object", + Params={ + "Bucket": bucket, + "Key": s3_key, + "ResponseContentType": mime_type, + "ResponseContentDisposition": f'attachment; filename="{filename}"', + }, + ExpiresIn=_DOWNLOAD_URL_TTL, + ) + + size_kb = f"{len(file_bytes) / 1024:.1f} KB" + return upload_id, download_url, size_kb + + +def _download_card(filename: str, download_url: str, size_kb: str, verb: str) -> str: + """Build the promoted inline-download-card tool result (JSON string). + + The ``ui_type``/``ui_display: inline`` discriminators make the frontend + render a first-class download card (see inline-visual.component.ts, + ``file_download``) instead of burying the link in the collapsed tool card. + The renderer picks its icon from the filename extension, so a single + ``file_download`` ui_type serves Word, Excel, and any future office file. + """ + return json.dumps( + { + "success": True, + "ui_type": "file_download", + "ui_display": "inline", + "payload": { + "filename": filename, + "download_url": download_url, + "size_kb": size_kb, + }, + "summary": ( + f"{verb} {filename} ({size_kb}). Also saved to this chat's Files." + ), + } + ) + + +def _error(text: str) -> Dict[str, Any]: + return {"content": [{"text": text}], "status": "error"} + + +_NO_CI_MESSAGE = ( + "❌ Code Interpreter is not configured. AGENTCORE_CODE_INTERPRETER_ID was " + "not found in the environment or Parameter Store." +) diff --git a/backend/src/agents/builtin_tools/word_document_tool.py b/backend/src/agents/builtin_tools/word_document_tool.py index e336fb7ad..fc6d7ba49 100644 --- a/backend/src/agents/builtin_tools/word_document_tool.py +++ b/backend/src/agents/builtin_tools/word_document_tool.py @@ -20,10 +20,10 @@ Design notes ------------ -* Code Interpreter usage mirrors ``code_interpreter_diagram_tool.py`` — the - interpreter id is resolved from ``AGENTCORE_CODE_INTERPRETER_ID`` (or SSM), - a session is started with ``CodeInterpreter(region).start(identifier=...)``, - and always stopped in a ``finally`` block. +* The Code Interpreter + user-files storage plumbing is shared with the Excel + toolset and lives in ``builtin_tools.office._storage``; this module keeps + only the python-docx specifics (preamble, generate/modify/extract) and the + four tool factories. * Identity (``user_id`` / ``session_id``) is captured by closure via the ``make_*`` factories — the same pattern used by the artifacts and spreadsheet_analysis tools (the Strands runtime here does NOT populate @@ -36,21 +36,27 @@ from __future__ import annotations import asyncio -import base64 -import json import logging -import os -import re -import uuid -from datetime import datetime, timezone -from typing import Any, Dict, Optional, Tuple - -import boto3 -from botocore.config import Config -from botocore.exceptions import ClientError +from typing import Any, Dict, Optional from strands import tool +from agents.builtin_tools.office._storage import ( + _DocGenError, + _ci_exec, + _ci_read_bytes, + _ci_write_bytes, + _download_card, + _download_s3_bytes, + _error, + _get_code_interpreter_id, + _NO_CI_MESSAGE, + _region, + _storage_configured, + _store_document, + _validate_document_name, +) + logger = logging.getLogger(__name__) # Word document MIME type (matches apis.shared.files.ALLOWED_MIME_TYPES). @@ -58,224 +64,20 @@ "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ) -# Presigned download links are short-lived; long enough for the user to click. -_DOWNLOAD_URL_TTL = 60 * 60 # 1 hour - # Sandbox path used to stage a source document loaded from S3. _SANDBOX_SOURCE = "_source.docx" - -class _DocGenError(Exception): - """Raised when Code Interpreter fails to run the document code.""" - - -class _StorageNotConfiguredError(Exception): - """Raised when the user-files S3 bucket is not configured for the runtime.""" - - -def _region() -> str: - return ( - os.environ.get("AWS_REGION") - or os.environ.get("AWS_DEFAULT_REGION") - or "us-west-2" - ) - - -def _get_code_interpreter_id() -> Optional[str]: - """Resolve the Custom Code Interpreter id (env first, then SSM).""" - ci_id = os.getenv("AGENTCORE_CODE_INTERPRETER_ID") - if ci_id: - return ci_id - try: - project_name = os.getenv("PROJECT_NAME", "strands-agent-chatbot") - environment = os.getenv("ENVIRONMENT", "dev") - ssm = boto3.client("ssm", region_name=_region()) - resp = ssm.get_parameter( - Name=f"/{project_name}/{environment}/agentcore/code-interpreter-id" - ) - return resp["Parameter"]["Value"] - except Exception as exc: # pragma: no cover - best-effort fallback - logger.warning(f"Code Interpreter id not found in env or SSM: {exc}") - return None - - -def _validate_document_name(name: str) -> Tuple[bool, Optional[str]]: - """Validate a document name (without extension). - - Rules: letters, numbers, hyphens and underscores only; no spaces or other - special characters; no consecutive, leading, or trailing hyphens. - """ - if not name: - return False, "Document name cannot be empty" - - if not re.match(r"^[a-zA-Z0-9_\-]+$", name): - invalid = sorted(set(re.findall(r"[^a-zA-Z0-9_\-]", name))) - return ( - False, - f"Invalid characters in name: {invalid}. Use only letters, " - "numbers, hyphens, and underscores.", - ) - if "--" in name: - return False, "Name cannot contain consecutive hyphens (--)" - if name.startswith("-") or name.endswith("-"): - return False, "Name cannot start or end with a hyphen" - return True, None - - -_s3_client = None -_bucket_region: Optional[str] = None - - -def _resolve_bucket_region(bucket: str) -> str: - """Discover the user-files bucket's real region. - - The AgentCore Runtime's ``AWS_REGION`` does not reliably match the - deployment/bucket region. Pinning the S3 client to the wrong region makes - ``PutObject`` fail with ``PermanentRedirect``. ``get_bucket_location`` is - region-agnostic (queried against us-east-1) and returns the true region; - a null ``LocationConstraint`` means us-east-1. Falls back to the env - region if the lookup is unavailable (e.g. missing s3:GetBucketLocation) — - ``PutObject`` still succeeds in that case because the client below no - longer hard-pins ``endpoint_url``, so botocore's built-in S3 region - redirect can correct it. - """ - global _bucket_region - if _bucket_region: - return _bucket_region - # HeadBucket (maps to s3:ListBucket, which the runtime role has) returns - # the true region in the ``x-amz-bucket-region`` header — on a 200 when - # probed from the matching region and on the 301 otherwise. This avoids - # depending on s3:GetBucketLocation, which the inference-api role is not - # granted. - region = None - try: - probe = boto3.client("s3", region_name="us-east-1") - resp = probe.head_bucket(Bucket=bucket) - region = ( - resp.get("ResponseMetadata", {}) - .get("HTTPHeaders", {}) - .get("x-amz-bucket-region") - ) - except ClientError as exc: - region = ( - exc.response.get("ResponseMetadata", {}) - .get("HTTPHeaders", {}) - .get("x-amz-bucket-region") - ) - if not region: - logger.warning(f"Could not resolve region for bucket {bucket}: {exc}") - except Exception as exc: # pragma: no cover - fall back to env region - logger.warning(f"Could not resolve region for bucket {bucket}: {exc}") - _bucket_region = region or _region() - return _bucket_region - - -def _s3(): - """SigV4 S3 client pinned to the user-files bucket's actual region. - - Uses the bucket's real region (not ``AWS_REGION``) so ``PutObject`` never - hits ``PermanentRedirect`` in the AgentCore Runtime. No explicit - ``endpoint_url``: botocore then builds the correct regional virtual-host - endpoint (which keeps presigned download URLs CORS-safe) and can still - auto-correct the region if the resolved value is off. - """ - global _s3_client - if _s3_client is None: - region = _resolve_bucket_region(_user_files_bucket()) - _s3_client = boto3.client( - "s3", - region_name=region, - config=Config( - signature_version="s3v4", - s3={"addressing_style": "virtual"}, - ), - ) - return _s3_client - - -def _user_files_bucket() -> str: - # Fail loudly rather than silently targeting a literal "user-files" bucket - # the runtime has no access to. That default misreported a missing env var - # as an S3 PermanentRedirect / AccessDenied and cost real debugging time. - # The runtime env is wired in infrastructure's - # inference-agentcore-construct.ts (S3_USER_FILES_BUCKET_NAME). - bucket = os.environ.get("S3_USER_FILES_BUCKET_NAME") - if not bucket: - raise _StorageNotConfiguredError( - "S3_USER_FILES_BUCKET_NAME is not set; the runtime cannot store or " - "retrieve Word documents." - ) - return bucket +_NO_STORAGE_MESSAGE = ( + "❌ Word document storage is not configured " + "(S3_USER_FILES_BUCKET_NAME is not set on the runtime)." +) # --------------------------------------------------------------------------- -# Code Interpreter primitives +# python-docx document builders (run in Code Interpreter) # --------------------------------------------------------------------------- -def _ci_exec(code_interpreter, code: str) -> str: - """Run Python in the sandbox; return stdout or raise _DocGenError.""" - response = code_interpreter.invoke( - "executeCode", - {"code": code, "language": "python", "clearContext": False}, - ) - stdout = "" - for event in response.get("stream", []): - result = event.get("result", {}) - if result.get("isError", False): - stderr = result.get("structuredContent", {}).get( - "stderr", "Unknown error" - ) - logger.error(f"Code Interpreter error: {stderr[:500]}") - raise _DocGenError(stderr[:1000]) - out = result.get("structuredContent", {}).get("stdout", "") - if out: - stdout += out - return stdout - - -def _ci_read_bytes(code_interpreter, filename: str) -> Optional[bytes]: - """Read a file out of the sandbox as bytes (or None if missing).""" - download = code_interpreter.invoke("readFiles", {"paths": [filename]}) - content = None - for event in download.get("stream", []): - result = event.get("result", {}) - for block in result.get("content", []) or []: - if "data" in block: - content = block["data"] - elif "resource" in block and "blob" in block["resource"]: - content = block["resource"]["blob"] - if content: - break - if content: - break - if content is None: - return None - # Code Interpreter may hand back raw bytes or a base64 string. - if isinstance(content, str): - content = base64.b64decode(content) - return content - - -def _ci_write_bytes(code_interpreter, path: str, data: bytes) -> None: - """Write binary bytes into the sandbox (base64 text + decode in-sandbox).""" - b64 = base64.b64encode(data).decode("ascii") - code_interpreter.invoke( - "writeFiles", - {"content": [{"path": f"{path}.b64", "text": b64}]}, - ) - _ci_exec( - code_interpreter, - ( - "import base64\n" - f"with open({path + '.b64'!r}) as _f:\n" - " _raw = base64.b64decode(_f.read())\n" - f"with open({path!r}, 'wb') as _o:\n" - " _o.write(_raw)\n" - ), - ) - - _DOCX_PREAMBLE = ( "from docx import Document\n" "from docx.shared import Pt, RGBColor, Inches\n" @@ -399,16 +201,10 @@ def _extract_docx_text(code_interpreter_id: str, source_bytes: bytes) -> str: # --------------------------------------------------------------------------- -# User-files store helpers +# User-files lookup # --------------------------------------------------------------------------- -def _download_s3_bytes(bucket: str, key: str) -> bytes: - """Read an object's bytes from S3 (blocking — use ``asyncio.to_thread``).""" - resp = _s3().get_object(Bucket=bucket, Key=key) - return resp["Body"].read() - - async def _find_word_document( user_id: str, session_id: str, document_name: str ): @@ -437,107 +233,6 @@ async def _find_word_document( return None -async def _store_document( - user_id: str, session_id: str, filename: str, file_bytes: bytes -) -> Tuple[str, str, str]: - """Persist the .docx to the user-files store and mint a download URL. - - Returns ``(upload_id, download_url, size_kb)``. - """ - from apis.shared.files import ( - FileMetadata, - FileStatus, - get_file_upload_repository, - ) - - bucket = _user_files_bucket() - timestamp_hex = format( - int(datetime.now(timezone.utc).timestamp() * 1000), "x" - ) - upload_id = f"{timestamp_hex}_{uuid.uuid4().hex[:16]}" - s3_key = f"user-files/{user_id}/{session_id}/{upload_id}/{filename}" - - await asyncio.to_thread( - _s3().put_object, - Bucket=bucket, - Key=s3_key, - Body=file_bytes, - ContentType=_DOCX_MIME, - ) - - metadata = FileMetadata( - upload_id=upload_id, - user_id=user_id, - session_id=session_id, - filename=filename, - mime_type=_DOCX_MIME, - size_bytes=len(file_bytes), - s3_key=s3_key, - s3_bucket=bucket, - status=FileStatus.READY, - ) - await get_file_upload_repository().create_file(metadata) - - download_url = await asyncio.to_thread( - _s3().generate_presigned_url, - "get_object", - Params={ - "Bucket": bucket, - "Key": s3_key, - "ResponseContentType": _DOCX_MIME, - "ResponseContentDisposition": f'attachment; filename="{filename}"', - }, - ExpiresIn=_DOWNLOAD_URL_TTL, - ) - - size_kb = f"{len(file_bytes) / 1024:.1f} KB" - return upload_id, download_url, size_kb - - -def _download_card(filename: str, download_url: str, size_kb: str, verb: str) -> str: - """Build the promoted inline-download-card tool result (JSON string). - - The ``ui_type``/``ui_display: inline`` discriminators make the frontend - render a first-class download card (see inline-visual.component.ts, - ``word_document``) instead of burying the link in the collapsed tool card. - """ - return json.dumps( - { - "success": True, - "ui_type": "word_document", - "ui_display": "inline", - "payload": { - "filename": filename, - "download_url": download_url, - "size_kb": size_kb, - }, - "summary": ( - f"{verb} {filename} ({size_kb}). Also saved to this chat's Files." - ), - } - ) - - -def _error(text: str) -> Dict[str, Any]: - return {"content": [{"text": text}], "status": "error"} - - -_NO_CI_MESSAGE = ( - "❌ Code Interpreter is not configured. AGENTCORE_CODE_INTERPRETER_ID was " - "not found in the environment or Parameter Store." -) - -_NO_STORAGE_MESSAGE = ( - "❌ Word document storage is not configured " - "(S3_USER_FILES_BUCKET_NAME is not set on the runtime)." -) - - -def _storage_configured() -> bool: - """True when the user-files bucket env var is set.""" - return bool(os.environ.get("S3_USER_FILES_BUCKET_NAME")) - - # --------------------------------------------------------------------------- # Tool factories # --------------------------------------------------------------------------- @@ -620,7 +315,7 @@ async def create_word_document( try: _id, download_url, size_kb = await _store_document( - user_id, session_id, filename, file_bytes + user_id, session_id, filename, file_bytes, _DOCX_MIME ) except Exception as exc: # noqa: BLE001 - storage failure is terminal logger.error(f"create_word_document storage error: {exc}") @@ -711,7 +406,7 @@ async def modify_word_document( try: _id, download_url, size_kb = await _store_document( - user_id, session_id, output_filename, file_bytes + user_id, session_id, output_filename, file_bytes, _DOCX_MIME ) except Exception as exc: # noqa: BLE001 - storage failure is terminal logger.error(f"modify_word_document storage error: {exc}") diff --git a/backend/src/apis/inference_api/chat/routes.py b/backend/src/apis/inference_api/chat/routes.py index 1e37af085..b69b535d3 100644 --- a/backend/src/apis/inference_api/chat/routes.py +++ b/backend/src/apis/inference_api/chat/routes.py @@ -487,6 +487,50 @@ def _build_word_document_tools( return tools +# ============================================================ +# Excel Spreadsheet Tool Injection +# ============================================================ + +EXCEL_SPREADSHEET_TOOL_IDS = {"create_excel_spreadsheet"} + + +def _build_excel_spreadsheet_tools( + enabled_tools: list | None, + session_id: str, + user_id: str, +) -> list: + """Create context-bound Excel spreadsheet tools if enabled by the user. + + Identity is captured by closure (same pattern as the Word document and + spreadsheet analysis tools) since the runtime does not populate ToolContext. + Distinct from the spreadsheet *analysis* tools (list_spreadsheets / + analyze_spreadsheet): this toolset creates/modifies/reads/lists generated + .xlsx files, it doesn't analyze uploaded ones. + """ + if not enabled_tools or not EXCEL_SPREADSHEET_TOOL_IDS.intersection(enabled_tools): + return [] + + # The Excel capability is a single toggle: enabling create_excel_spreadsheet + # provisions the full workbook toolset (create/modify/list/read) so the + # model can round-trip on a spreadsheet without extra admin catalog entries. + from agents.builtin_tools.excel_spreadsheet_tool import ( + make_create_excel_spreadsheet_tool, + make_list_excel_spreadsheets_tool, + make_modify_excel_spreadsheet_tool, + make_read_excel_spreadsheet_tool, + ) + + tools = [ + make_create_excel_spreadsheet_tool(session_id, user_id), + make_modify_excel_spreadsheet_tool(session_id, user_id), + make_list_excel_spreadsheets_tool(session_id, user_id), + make_read_excel_spreadsheet_tool(session_id, user_id), + ] + + logger.info(f"Created {len(tools)} excel spreadsheet tools") + return tools + + def _build_memory_tools(agent_memory, user_id: str, user_email: str) -> list: """Context-bound Memory-Space tools for an Agent's resolved memory binding. @@ -1793,6 +1837,10 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g enabled_tools=effective_enabled_tools, session_id=input_data.session_id, user_id=user_id, + ) + _build_excel_spreadsheet_tools( + enabled_tools=effective_enabled_tools, + session_id=input_data.session_id, + user_id=user_id, ) + _build_memory_tools( agent_memory=agent_memory, user_id=user_id, diff --git a/backend/tests/test_seed_system_admin_jwt.py b/backend/tests/test_seed_system_admin_jwt.py index aece22925..4eeb6e112 100644 --- a/backend/tests/test_seed_system_admin_jwt.py +++ b/backend/tests/test_seed_system_admin_jwt.py @@ -131,7 +131,7 @@ def test_creates_default_tools(self, dynamodb_table): """Creates the default tool entries.""" result = seed_default_tools(TABLE_NAME, REGION) - assert result.created == 6 + assert result.created == 7 assert result.failed == 0 # Verify fetch_url_content @@ -216,13 +216,27 @@ def test_creates_default_tools(self, dynamodb_table): assert item["GSI1PK"] == "CATEGORY#document" assert item["GSI1SK"] == "TOOL#create_word_document" + # Verify create_excel_spreadsheet (single toggle for the whole Excel toolset) + resp = dynamodb_table.get_item( + Key={"PK": "TOOL#create_excel_spreadsheet", "SK": "METADATA"} + ) + item = resp["Item"] + assert item["toolId"] == "create_excel_spreadsheet" + assert item["displayName"] == "Excel Spreadsheets" + assert item["category"] == "document" + assert item["protocol"] == "local" + assert item["enabledByDefault"] is False + assert item["isPublic"] is True + assert item["GSI1PK"] == "CATEGORY#document" + assert item["GSI1SK"] == "TOOL#create_excel_spreadsheet" + def test_skips_existing_tools(self, dynamodb_table): """Skips tools that already exist.""" seed_default_tools(TABLE_NAME, REGION) result = seed_default_tools(TABLE_NAME, REGION) - assert result.skipped == 6 + assert result.skipped == 7 assert result.created == 0 def test_partial_skip(self, dynamodb_table): @@ -236,7 +250,7 @@ def test_partial_skip(self, dynamodb_table): result = seed_default_tools(TABLE_NAME, REGION) - assert result.created == 5 + assert result.created == 6 assert result.skipped == 1 diff --git a/backend/uv.lock b/backend/uv.lock index 48a5cada1..4c6376832 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -12,7 +12,7 @@ resolution-markers = [ [[package]] name = "agentcore-stack" -version = "1.9.0" +version = "1.10.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, diff --git a/frontend/ai.client/package-lock.json b/frontend/ai.client/package-lock.json index 0e6b860e5..87f5f8574 100644 --- a/frontend/ai.client/package-lock.json +++ b/frontend/ai.client/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai.client", - "version": "1.9.0", + "version": "1.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai.client", - "version": "1.9.0", + "version": "1.10.0", "dependencies": { "@angular/cdk": "21.2.14", "@angular/common": "21.2.17", diff --git a/frontend/ai.client/package.json b/frontend/ai.client/package.json index 051419903..69bae0039 100644 --- a/frontend/ai.client/package.json +++ b/frontend/ai.client/package.json @@ -1,6 +1,6 @@ { "name": "ai.client", - "version": "1.9.0", + "version": "1.10.0", "scripts": { "ng": "ng", "start": "ng serve", diff --git a/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/inline-visual.component.ts b/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/inline-visual.component.ts index 335c18354..3cb42f87c 100644 --- a/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/inline-visual.component.ts +++ b/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/inline-visual.component.ts @@ -1,7 +1,7 @@ import { Component, input, computed, inject, ChangeDetectionStrategy } from '@angular/core'; import { ChartRendererComponent } from './renderers/chart-renderer.component'; import { DefaultRendererComponent } from './renderers/default-renderer.component'; -import { WordDocumentRendererComponent } from './renderers/word-document-renderer.component'; +import { FileDownloadRendererComponent } from './renderers/file-download-renderer.component'; import { VisualStateService } from '../../../../services/visual-state/visual-state.service'; /** @@ -11,7 +11,7 @@ import { VisualStateService } from '../../../../services/visual-state/visual-sta @Component({ selector: 'app-inline-visual', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ChartRendererComponent, DefaultRendererComponent, WordDocumentRendererComponent], + imports: [ChartRendererComponent, DefaultRendererComponent, FileDownloadRendererComponent], template: ` @if (!isDismissed()) {
@@ -24,8 +24,13 @@ import { VisualStateService } from '../../../../services/visual-state/visual-sta (toggleExpanded)="onToggleExpanded()" /> } + @case ('file_download') { + + } @case ('word_document') { - + + } @default { + +
+ +
+ + +
+

+ {{ f.filename }} +

+ @if (f.size_kb) { +

{{ f.size_kb }}

+ } +
+ + + + + Download + +
+ } + `, +}) +export class FileDownloadRendererComponent { + /** The payload data from the backend tool result. */ + payload = input.required(); + + /** Narrowed, validated payload with resolved icon styling (null when malformed). */ + file = computed<(FileDownloadPayload & FileKindStyle) | null>(() => { + const raw = this.payload(); + if (!raw || typeof raw !== 'object') return null; + const p = raw as Partial; + if (!p.filename || !p.download_url) return null; + return { + filename: p.filename, + download_url: p.download_url, + size_kb: p.size_kb, + ...styleForFilename(p.filename), + }; + }); +} diff --git a/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/renderers/word-document-renderer.component.ts b/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/renderers/word-document-renderer.component.ts deleted file mode 100644 index 53a688d9c..000000000 --- a/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/renderers/word-document-renderer.component.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; - -/** - * Payload for the word_document inline visual, produced by the - * create_word_document tool. download_url is a short-lived presigned S3 GET - * URL whose response forces Content-Disposition: attachment, so a plain - * click downloads the file (no new tab / navigation needed). - */ -interface WordDocumentPayload { - filename: string; - download_url: string; - size_kb?: string; -} - -/** - * Inline download card for a generated Word document. Rendered as a - * first-class message block (not inside the collapsed tool-output card), so - * the download action is always visible and clickable. - * - * The download link uses the trailing-! important modifiers (text-white! and - * no-underline!) because the global ".message-block a" rule in styles.css - * (dark-blue text + underline) outranks a plain text-white utility by - * specificity. The ! modifier emits !important, which wins regardless. - */ -@Component({ - selector: 'app-word-document-renderer', - changeDetection: ChangeDetectionStrategy.OnPush, - host: { class: 'block' }, - template: ` - @if (doc(); as d) { -
- -
- -
- - -
-

- {{ d.filename }} -

- @if (d.size_kb) { -

{{ d.size_kb }}

- } -
- - - - - Download - -
- } - `, -}) -export class WordDocumentRendererComponent { - /** The payload data from the backend tool result. */ - payload = input.required(); - - /** Narrowed, validated payload (null when malformed). */ - doc = computed(() => { - const raw = this.payload(); - if (!raw || typeof raw !== 'object') return null; - const p = raw as Partial; - if (!p.filename || !p.download_url) return null; - return { filename: p.filename, download_url: p.download_url, size_kb: p.size_kb }; - }); -} diff --git a/infrastructure/lib/constructs/spa/spa-distribution-construct.ts b/infrastructure/lib/constructs/spa/spa-distribution-construct.ts index 604a66ae6..71f2e64ef 100644 --- a/infrastructure/lib/constructs/spa/spa-distribution-construct.ts +++ b/infrastructure/lib/constructs/spa/spa-distribution-construct.ts @@ -25,6 +25,14 @@ export interface SpaDistributionConstructProps { * the resolved string. */ appApiUrl: string; + /** + * MCP Apps sandbox-proxy origin (`https://mcp-sandbox.{domainName}` on + * domained deploys). Included in the `frame-src` CSP directive so the + * SPA can frame MCP App UIs; without it CloudFront's CSP blocks the + * sandbox iframe (localhost dev bypasses CloudFront, so the block only + * shows up on deployed environments). + */ + mcpSandboxOrigin: string; } /** @@ -46,8 +54,9 @@ export interface SpaDistributionConstructProps { * embedding), Referrer-Policy=strict-origin-when-cross-origin, HSTS * 1y w/ subdomains, X-XSS-Protection. * - The `frame-src` CSP directive opens `https://artifacts.{domainName}` - * so the SPA can embed artifact iframes. Other resource types remain unrestricted - * by CSP (defended by the other security headers). + * and the MCP Apps sandbox-proxy origin (`https://mcp-sandbox.{domainName}`) + * so the SPA can embed artifact and MCP App iframes. Other resource types + * remain unrestricted by CSP (defended by the other security headers). * * Custom domain: if `config.domainName` and * `config.frontend.certificateArn` are both set, the distribution @@ -70,7 +79,7 @@ export class SpaDistributionConstruct extends Construct { ) { super(scope, id); - const { config, bucket, appApiUrl } = props; + const { config, bucket, appApiUrl, mcpSandboxOrigin } = props; // OAC for CloudFront → S3 access (S3 bucket has block-public-access). new cloudfront.CfnOriginAccessControl(this, 'FrontendOAC', { @@ -129,7 +138,7 @@ export class SpaDistributionConstruct extends Construct { ...(artifactsOrigin ? { contentSecurityPolicy: { - contentSecurityPolicy: `frame-src 'self' ${artifactsOrigin}`, + contentSecurityPolicy: `frame-src 'self' ${artifactsOrigin} ${mcpSandboxOrigin}`, override: true, }, } diff --git a/infrastructure/lib/platform-stack.ts b/infrastructure/lib/platform-stack.ts index 5ed350997..e6a36ee79 100644 --- a/infrastructure/lib/platform-stack.ts +++ b/infrastructure/lib/platform-stack.ts @@ -617,6 +617,7 @@ export class PlatformStack extends cdk.Stack { config, bucket: this.spaBucket, appApiUrl: this._albDns.albUrl, + mcpSandboxOrigin: this.mcpSandboxProxyOrigin, }); this.spaDistribution = spaDist.distribution; this.spaDistributionDomainName = spaDist.distributionDomainName; diff --git a/infrastructure/package-lock.json b/infrastructure/package-lock.json index d9666af9b..c694c14a3 100644 --- a/infrastructure/package-lock.json +++ b/infrastructure/package-lock.json @@ -1,12 +1,12 @@ { "name": "infrastructure", - "version": "1.9.0", + "version": "1.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "infrastructure", - "version": "1.9.0", + "version": "1.10.0", "dependencies": { "aws-cdk-lib": "2.260.0", "constructs": "10.6.0" diff --git a/infrastructure/package.json b/infrastructure/package.json index d12452285..4f0ed2a23 100644 --- a/infrastructure/package.json +++ b/infrastructure/package.json @@ -1,6 +1,6 @@ { "name": "infrastructure", - "version": "1.9.0", + "version": "1.10.0", "bin": { "infrastructure": "bin/infrastructure.js" }, diff --git a/infrastructure/test/spa-frame-src-csp.test.ts b/infrastructure/test/spa-frame-src-csp.test.ts new file mode 100644 index 000000000..e7d0c3561 --- /dev/null +++ b/infrastructure/test/spa-frame-src-csp.test.ts @@ -0,0 +1,88 @@ +/** + * Regression cover for the SPA distribution's `frame-src` CSP. + * + * The SPA frames two cross-origin iframes on domained deploys: artifact + * previews (`artifacts.{domain}`) and MCP App UIs via the sandbox proxy + * (`mcp-sandbox.{domain}`). The sandbox side's `frame-ancestors` was + * locked to the SPA origin from the start, but the SPA side's + * `frame-src` originally listed only the artifacts origin — so deployed + * environments blocked every MCP App iframe with a CSP violation while + * localhost dev (which bypasses CloudFront's response headers) worked, + * masking the gap through live verification. + */ +import * as cdk from 'aws-cdk-lib'; +import { Template } from 'aws-cdk-lib/assertions'; +import { loadConfig } from '../lib/config'; +import { PlatformStack } from '../lib/platform-stack'; +import { mockSsmContext, MOCK_ACCOUNT, MOCK_REGION } from './helpers/mock-config'; + +const SHARED_CF_CERT = 'arn:aws:acm:us-east-1:123456789012:certificate/shared-wildcard'; + +/** Seed every context value loadConfig requires for a domained deploy. */ +function seedRequiredContext(app: cdk.App): void { + app.node.setContext('projectPrefix', 'test-project'); + app.node.setContext('awsRegion', MOCK_REGION); + app.node.setContext('awsAccount', MOCK_ACCOUNT); + app.node.setContext('vpcCidr', '10.0.0.0/16'); + app.node.setContext('production', false); + app.node.setContext('retainDataOnDelete', false); + app.node.setContext('domainName', 'example.com'); + app.node.setContext('infrastructureHostedZoneDomain', 'example.com'); + app.node.setContext('frontend', { cloudFrontPriceClass: 'PriceClass_100' }); + app.node.setContext('appApi', { cpu: 256, memory: 512, desiredCount: 1, maxCapacity: 2 }); + app.node.setContext('inferenceApi', {}); + app.node.setContext('fineTuning', {}); + app.node.setContext('artifacts', { retentionDays: 90, extraFrameAncestors: [] }); + app.node.setContext('mcpSandbox', { extraFrameAncestors: [] }); + app.node.setContext('ragIngestion', { + additionalCorsOrigins: '', + lambdaMemorySize: 10240, + lambdaTimeout: 900, + embeddingModel: 'amazon.titan-embed-text-v2', + vectorDimension: 1024, + vectorDistanceMetric: 'cosine', + }); +} + +describe('SPA distribution frame-src CSP', () => { + const PREV = process.env.CDK_CLOUDFRONT_CERTIFICATE_ARN; + + afterAll(() => { + if (PREV === undefined) { + delete process.env.CDK_CLOUDFRONT_CERTIFICATE_ARN; + } else { + process.env.CDK_CLOUDFRONT_CERTIFICATE_ARN = PREV; + } + }); + + it('allows both the artifacts and mcp-sandbox origins on a domained deploy', () => { + delete process.env.CDK_FRONTEND_CERTIFICATE_ARN; + delete process.env.CDK_ARTIFACTS_CERTIFICATE_ARN; + delete process.env.CDK_MCP_SANDBOX_CERTIFICATE_ARN; + process.env.CDK_CLOUDFRONT_CERTIFICATE_ARN = SHARED_CF_CERT; + + const app = new cdk.App(); + seedRequiredContext(app); + const config = loadConfig(app); + mockSsmContext(app, config); + + const stack = new PlatformStack(app, 'FrameSrcCspPlatformStack', { + config, + env: { account: MOCK_ACCOUNT, region: MOCK_REGION }, + }); + const template = Template.fromStack(stack); + + template.hasResourceProperties('AWS::CloudFront::ResponseHeadersPolicy', { + ResponseHeadersPolicyConfig: { + Name: 'test-project-frontend-headers', + SecurityHeadersConfig: { + ContentSecurityPolicy: { + ContentSecurityPolicy: + "frame-src 'self' https://artifacts.example.com https://mcp-sandbox.example.com", + Override: true, + }, + }, + }, + }); + }); +}); diff --git a/infrastructure/test/transport-security.test.ts b/infrastructure/test/transport-security.test.ts index aa10d7c8e..9724c81e5 100644 --- a/infrastructure/test/transport-security.test.ts +++ b/infrastructure/test/transport-security.test.ts @@ -59,6 +59,7 @@ describe('Transport security baseline', () => { config, bucket: bucket.bucket, appApiUrl: 'https://api.example.com', + mcpSandboxOrigin: 'https://mcp-sandbox.example.com', }); const t = Template.fromStack(stack);