Skip to content

[feat] Run agent HTML apps from the drive - #6972

Merged
mmabrouk merged 46 commits into
release/v0.120.0from
feat/agent-apps
Sep 22, 2026
Merged

mmabrouk merged 46 commits into
release/v0.120.0from
feat/agent-apps

Conversation

@ardaerzin

@ardaerzin ardaerzin commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Context

An agent can already write an HTML file into its drive, but the drive could only show it: a static Preview and the Source. Anything the person was meant to touch (a board, a checklist, a queue) rendered as a dead page, and nothing the page did could survive a reload, because the page had no way to read or write the files next to it.

This is phase 1 of agent HTML apps: an app folder in the agent's drive runs in a sandboxed iframe, and the page talks to its own folder through a window.agenta bridge. Everything is behind the agent-apps flag, off by default.

Changes

A third tab on HTML files. The drive's HTML viewer gains Run next to Preview and Source, shown only when the flag is on. Choosing it asks for a grant (read, or read and write) scoped to that one folder, then renders the folder's entry file on the bridge host.

The bridge. The host posts a hello with a transferred MessagePort; after that the iframe talks over the port only. window.agenta.fs offers read, readJSON, write, writeJSON, list, exists, stat and remove. The write methods need the read-write grant. The host also pushes visibility, theme (so an app recolours on a theme switch without a reload) and changed when files move under the app's feet.

Conditional writes on mount files. Mount file reads now return an etag, and PUT/DELETE accept If-Match. A stale precondition fails with 412 and hands back the current etag, so the bridge retries rather than clobbering:

READ  apps/launch-board/board.json        -> 200 etag=9835c5ef...
PUT   If-Match: "deadbeef"                -> 412 {"code":"conflict","etag":"9835c5ef..."}
PUT   If-Match: 9835c5ef...               -> 200

That precondition lives on the mounts API, not on apps, so every drive writer gets it.

create_app and list_starters. Two platform ops let the agent copy a starter into apps/<slug>/ rather than hand-writing a page. One starter ships in this PR (board@1). An agent can keep its own starters in agent-files/.apps/starters/, referenced as agent:<name>.

The agenta-apps skill. Assembled from sections and attached to the build kit by default, so an agent knows when an app is the right answer, what the folder looks like, and how the bridge behaves.

An app folder is app.json (the agenta_app: 1 manifest: name, entry, access, data files, config) plus the entry page and its data as sibling JSON. One writer per file: the agent owns config, the app owns its data.

The folder is a server boundary, which reverses what this PR first claimed

This PR originally said the server bounds the mount while folder scope is a frontend guardrail, and the design docs said so too. That rested on one assumption: an app has no way to get data out, so a bug in the parent's path check could not be turned into theft. The assumption was tested and failed three times.

  1. Popups. CSP fetch directives do not govern navigation, and navigate-to never shipped in any browser, so no CSP value blocks window.open. With allow-popups an app called window.open("https://…?d=" + stolen) inside a click it already handled. The request arrived at a listening server with the data.
  2. WebRTC. ICE is not a fetch, so no CSP value governs it either. A peer connection gathered an srflx candidate from inside the same sandbox while every HTTP channel was blocked. webrtc 'block' in a meta policy did nothing.
  3. A data: iframe in Preview. The preview assembler strips <script>, on*, javascript: and iframe[srcdoc], and that stripping was treated as the reason Preview could keep its popup flags. It never covered <iframe src="data:text/html,…">, whose nested context inherits allow-scripts and ran a script after the outer document had none left. Preview injected no CSP at all, so it could fetch anywhere.

Each was found by thinking of one more exit. Enumerating exits against a browser does not terminate, so the folder rule moved onto the server:

  • api/oss/src/core/apps/scope_token.py mints a prefix-scoped HMAC token; the mounts router checks it on every file call. It only narrows, so a request without one is an ordinary drive call and the Files pane is untouched.
  • Sandbox is now allow-scripts allow-forms, with form-action 'none' named explicitly because it does not inherit from default-src.
  • The bridge stub deletes the WebRTC constructors before any app code runs.
  • PREVIEW_CSP denies the capability rather than lengthening a strip list: frame-src and object-src 'none' close the nested contexts (<object data> and <embed> execute the same way), and default-src 'none' covers connect-src.

Two smaller holes closed alongside: a grant recorded at read no longer silently covers a manifest that later asks for read-write, and the Run assembler confines <link>, <img> and <script src> to the app folder, because markup could path-climb where the fs bridge always refused.

The bridge write also moved off a raw axios.put and onto the generated client. The blocker was that the generated writeMountFile carried no body; the endpoint now declares its raw body through openapi_extra and the client was regenerated (#6981).

Tests

  • api/oss/tests/pytest/unit/apps: 84 passed, including 26 for the scope token (signature, expiry, prefix normalisation, level).
  • @agenta/entities: 1927 passed. @agenta/entity-ui: 832 passed. Both suites green after the preview CSP change.
  • Package typecheck is identical to the base branch, and oss, ee and mobile types:check are clean.
  • The egress constants are pinned by tests, so restoring allow-popups or dropping the WebRTC removal fails a test rather than silently reopening a channel.

Verified in a real browser, not only in tests. The full scope-token loop was driven through the running app on the EE dev stack: mint under cookie auth 200; in-folder read 200; out-of-folder read with the token 403 scope; the same read without a token 200, which is what proves the token only narrows; a tampered signature 403 bad signature. A temporary log inside enforce() confirmed the header rides on the app's own reads rather than only on hand-made ones.

The earlier 16-step acceptance script was run on /m: 10 pass, 6 not run. The per-step table is in the first comment. The three steps that need a live agent turn are still not covered, and the desktop host was exercised only for the scope-token run above.

What to QA

Turn on Settings › Preferences › Agent apps first. You need an agent whose drive has an app folder; ask the agent for a board, or copy board@1 into apps/<slug>/.

  • Open an HTML file in an agent's drive. You get Preview | Source | Run. With the flag off, only Preview | Source.
  • Choose Run in a folder with an app.json that says read-write. The grant sheet offers write, preselected. A viewer-role member does not see the write option.
  • Grant read-write. The board renders from board.json and the strip reads Running · read + write · <dir>.
  • Drag a card. It saves, and the Files pane re-lists.
  • Reload the page and reopen the file. Run resumes without asking again. Close the browser and reopen: it asks again.
  • Ask the agent to add a card while the app is open. The board updates on its own, with no click.
  • Start a drag, have the agent write mid-drag, then drop. Both changes survive and the conflict is retried silently.
  • Grant read only, then drag. The move shows as unsaved and the strip says read_only.
  • Switch theme. The app recolours without a reload.
  • An app that reads ../something outside its folder is refused, and the refusal reaches the app as scope.
  • Regression: with the flag off, an HTML file in the drive previews exactly as it did before.
  • Regression: a previewed HTML file that links a remote stylesheet or image still renders it. Preview now carries a CSP, and this is the thing it could plausibly have broken.
  • Regression, worth knowing rather than testing: a previewed page containing an <iframe> no longer renders that frame. Run never could, because its CSP has always fallen back to default-src 'none' for frames, so this makes Preview match Run. Nothing in the drive templates embeds one.

Docs

The design documents are on #6983, committed rather than left untracked. docs/design/agent-html-apps/contracts.md on this branch carries the bridge contract and an egress section that names all three exits and why the list is a record of what is closed rather than proof that nothing is open.

Mount file reads and listings now return the object's etag (null for
folders). PUT honours If-Match and If-None-Match: *, DELETE honours
If-Match; a failed condition is 412 with
{"detail": {"code": "conflict", "etag": <current or null>}}.
No header keeps the unconditional behaviour.

PUT conditions ride natively as S3 headers (verified against the bundled
SeaweedFS); conditional delete is emulated as stat-compare-delete.
MountFileWrittenResponse gains etag; WriteMountFileRequest gains if-match
and if-none-match, DeleteMountFileRequest gains if-match, and the mounts
client forwards them as request headers. Generated from the app's OpenAPI
spec with clients/scripts/generate.sh; only the mounts-scoped delta is
kept, the rest of the regen output is unrelated generator drift.
Real bridge behind the lane 0 contract, headless:

- stub.ts: the script inlined into the app document. Installs window.agenta,
  adopts the hello's MessagePort, serialises fs calls to FsRequests (queued
  until hello), parses readJSON / stringifies writeJSON, rejects with .code
  and .etag, applies kit tokens as a <style id=agenta-tokens>, forwards
  window errors and unhandled rejections, intercepts internal link clicks
  as nav. Plain ES2017 built from a function's source so jsdom tests run
  the identical string.
- host.ts: parent side. hello + port on attach, port-only serving, order
  isFsRequest -> scope -> grant -> body -> client, implicit If-Match from
  the etag cache (skipped on force), cache refresh from every result,
  onWrite on write/remove, changed queued while detached. Matches the mock
  host's behavioural table.
- fsClient.ts: the eight operations against the mounts API. Fern for read
  (?read=), list (depth 1), exists/stat (parent listing) and delete
  (If-Match via requestOptions.headers); axios PUT with a raw text/plain
  body for write. 404/412/413/403 -> not_found/conflict/too_large/read_only,
  else unavailable; local READ_CAP/WRITE_CAP.
- scope.ts, grants.ts, etags.ts: dir join over normalizeAppPath, the
  sessionStorage-mirrored grant store, the per-path etag cache.

Tests: six suites (105 cases); the host runs the mock's table with a fake
client so the two provably agree.
Adds the kit stylesheet every agent-built HTML app is assembled with
(agenta-app.css, 12px base, KIT_CLASSES only, colour via --ag-* tokens,
no url()/@import/@font-face) and resolveKitTokens/tokensToCss, which map
KIT_TOKENS onto the host theme variables with light/dark fallbacks.

The CSS ships as a generated kitCss.ts string (no raw-css loader in the
package toolchain); scripts/sync-kit-css.mjs regenerates it and the unit
test fails on drift. Exposed as @agenta/entity-ui/drive/htmlApp/kit.
One sandboxed-iframe story per kit family (type and layout, controls,
surfaces, states, board sample, narrow board), each built exactly as the
assembler injects it: CSP meta, resolved tokens, KIT_CSS, then markup.
Tokens re-resolve when the theme toolbar toggles the .dark class.
axe inside the sandboxed story iframe flagged ok/warn badge text on its
tint and the aria-busy / data-dragging dims below 4.5:1. Badge tone text
now leans 25% toward --ag-fg (5.5:1 light, 4.7-6.4:1 dark), busy dims to
0.7 and dragging to 0.65.

tokensToCss follows the stub's sanitiser (names --[A-Za-z0-9_-]+, values
free of ;{}<>) and the test asserts every emitted token survives it. The
story srcDoc gains lang, a title and a main landmark for the samples.
….tsx

Move inlineHtmlAssets, HTML_NAV_INTERCEPTOR and their helpers unchanged into
drive/htmlApp/assemble.ts as assemblePreview (mount access injected through
AssembleIo, so the function is pure), and make HtmlBody a thin wrapper around
the new HtmlAppBody. Three fixtures lock the output byte-for-byte against the
pre-move code: the expected strings were captured by running the old function
under the same jsdom, and a verbatim copy of it stays in the test.
Behind userScopedFlagAtom(AGENT_APPS_FLAG) the HTML viewer gains a Run tab
that mounts the file's folder as an agent HTML app on the bridge host:

- assembleRunDocument: the Preview asset inlining, author scripts kept
  (same-folder script src inlined, external dropped with a note), CSP meta +
  kit tokens + kit CSS + bridge stub prepended to head. No nav interceptor.
- RunView: sandboxed iframe, host attach on load / detach on unmount, status
  strip (dot, grant, dir), Refresh menu with Reload files, error strip with
  count badge, expandable list and Copy, in-dir navigation with a back stack,
  theme observation pushing resolved kit tokens through host.setTheme.
- GrantSheet: Read / Read and write, preselected from the manifest, the write
  option gated by the drive's upload gate, a reserved hidden third section.
- useAppManifest reads <dir>/app.json; useChangedHint diffs the app dir's
  listing query and calls host.notifyChanged; onWrite bumps the listing via
  refreshMountListing.
- HtmlAppEnvContext lets a host (or a story) inject the host factory, mount
  io, kit CSS, token resolver and grant store. Until lane A lands the default
  host is the in-memory mock and the stub is a placeholder (TODO(lane A)).

Exports userScopedFlagAtom from @agenta/shared/state so a package can own a
flag's key.
Fixtures: a retro board written against the kit classes (index.html, app.json,
board.json), a two-page site sharing app.js and site.css, and a broken app that
throws on load and references a CDN script. createStoryHost wraps the mock so a
story can raise the two port-only signals (nav href, script error) from a
button; fixtureIo serves the host's live files to the assembler.

Stories under @agenta/entity-ui/Drive/HtmlApp: HtmlAppBody (flag off, flag on,
granted, loading, unavailable), GrantSheet (read preselected, read-write
preselected, write hidden, cancel), RunView (read-only, read-write, error strip,
conflict retried, changed hint pending, navigation back stack, not_found data,
too_large). HtmlApp.mdx maps each story to its acceptance step and names the
lane A placeholders.

Re-exports drive/htmlApp from @agenta/entity-ui/drive; joinPath renamed to
joinAppPath so it cannot shadow the @agenta/entities/drive export.
axe flags a missing html lang and a missing title. assembleRunDocument now
sets lang="en" when the author left it out and appends a <title> (the
manifest name, else the file name) when the page has none; the injected
CSP/tokens/kit/stub block still leads the head. RunView threads the title
through, HtmlAppBody passes the app name. The theme observer's comment now
states that the host stamps the theme as the .dark class on the root (that
attribute was already watched).
One paragraph after the .tools/ material in "Installing tools": what the
folder holds (layout.json, registry/, notes/, starters/, kit/), that it is
dimmed, not hidden, in the Files pane, that app data never lives there, and
that the agenta-apps skill owns the write rules.

Tests pin the paragraph's placement, its records, the data rule, and the
section header order. The gateway-connection golden embeds the composed
platform text, so its platformInstructions field is regenerated; no other
key changed.
Section 08: layout.json on first create_app, per-session registry rewrite,
merge-by-slug before creating, add-only notes on repeated feedback, the
scheduled-data prompt for agent-level placement, and the no-app-data /
no-kit rules. Assembled into SKILL.md by the skill lane.
AppsService resolves bundled starters (name or name@N), copies one into a
mount through MountsService.write_file, stamps template into app.json,
refuses an existing manifest unless update=True and then leaves the
manifest's data files and config alone. validate_manifest mirrors the
manifest.ts rules. The board starter is the reference custom app: kit
classes only, whole-file debounced writes, conflict merge, read-only and
changed handling.
Two reserved tools.agenta.* handlers registered in PLATFORM_TOOL_HANDLERS.
create_app resolves the session cwd mount from the run-bound session_id
and copies a starter; list_starters unions the bundle with the agent
mount's .apps/starters when the run binds an artifact id. The
model-facing definitions (description, schema, context bindings,
read_only) live next to the handlers as the source for the SDK op
catalog entries. Mount access goes through an interim process-wide
MountsService factory until dispatch passes the router's instance.
The skill text lives in skill/sections/*.md (1-2 KB units); SKILL.md is
checked in for readability and a test asserts it equals the assembly.
06-starters.md is rendered from the bundle so the table cannot drift.
The catalogue serves it as __ag__agenta_apps with each starter's
SKILL.md under references/starters/. 08-agent-level.md is a placeholder
owned by lane F. The two tests that pin the builtin skill set gain the
new slug.
Renders api/oss/src/core/apps/starters/board@1 in a sandboxed iframe
against createMockHtmlAppHost. The starter files are read at build time
through webpack's ?raw rule (raw.d.ts types the query import), so edits
show without copies. The fixture carries a story-only bridge stub and
kit stylesheet until lanes A and E land. Stories: empty, five cards,
read-only grant, conflict on drop (silent agent write), changed arrival
(externalWrite), dark via the theme global.
HtmlAppBody defaults to createHtmlAppHost, BRIDGE_STUB and KIT_CSS; the
createHost, bridgeStub and kitCss seams stay so stories inject the mock.
RunView and assemble drop their own token resolver and tokensToCss for the
kit's, and htmlApp re-exports ./kit. The stub keeps color-scheme when it
rewrites the token block so native controls follow the theme after hello.
Stories use KIT_CSS and BRIDGE_STUB in place of the stand-ins.
The regenerated DeleteMountFileRequest carries if-match, so the fs client
stops smuggling the header through requestOptions. getMountFiles is still
untyped (no response_model), so the local zod boundary stays.
…ispatch

create_app and list_starters now receive the MountsService the entrypoint
wires into ToolsRouter; the process-wide factory in handlers.py is gone and
a deployment without one answers 501 instead of building its own.
Lane D shipped 08-agent-level.md as a placeholder and sized the body
budget against it; lane F's real section is 1.7 KB. Body budget becomes
4.5 KB (total stays 6 KB), the ownership comment no longer reaches the
model, rule 5 proposes a schedule only when create_schedule is offered,
and SKILL.md is regenerated from the sections.
…text

The Files pane dims dot folders and lets the person open them, which is
what the .apps/ paragraph already says. The gateway-connection golden
embeds the text; only its platformInstructions field changes.
…talog

Both join PLATFORM_OPS as handler-mode ops and their call refs join the
handler allowlist, so a run can name them as platform tools. The entries
copy the API's tool definitions; an API unit test asserts they stay equal
and that each handler is registered.
…build kit

A playground agent now gets list_starters, create_app (both allow: they
act on the session's own drive, which write_files already opens) and the
__ag__agenta_apps skill. Unconditional, because the overlay has no drive
or feature gate; with the web flag off the result is still a previewable
HTML file.
…lived grants

The Files pane owns its Source | Preview toolbar and rendered the HTML body
with previewOnly, so Run was reachable only from the chat Quick Look. The
toolbar gains Run when the flag is on, and HtmlAppBody follows a host-owned
view (controlledView) through the same grant sheet.

The agent-apps atom moves to @agenta/shared/state so Settings > Preferences
on both hosts toggles the instance the viewer reads, without importing the
drive bundle. The default grant store is lane A's sessionStorage one: a
reload resumes Run without a prompt, a new browser session asks again.
…re faking

The real host dropped the cached etag on notifyChanged, so a write the app
had not merged went out unconditionally and replaced the agent's edit. The
contract and the mock say the opposite: the etag stays, the write conflicts,
the app re-reads and merges. The host now matches.

HtmlAppHost gains an optional onChanged(cb), implemented by both hosts. The
mock gains externalWrite(..., {silent}), emitNav and emitError, which replace
the story host wrapper and the files.set trick in ConflictOnDrop. contracts.md
records the changed rule, the helpers and the stub's token sanitiser.
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

📝 Summary

Summary by CodeRabbit

  • New Features

    • Added beta Agent Apps support for running interactive HTML apps from Drive.
    • Added app starter discovery and creation, including a configurable Board starter with drag-and-drop cards.
    • Added Preview, Source, and Run views with read or read-write access confirmation.
    • Added sandboxed execution with file access, theme support, navigation, change notifications, and error reporting.
    • Added scoped permissions, conditional file updates, and conflict detection for safer edits.
    • Added an “Agent apps” preference toggle.
  • Documentation

    • Added guidance for creating, managing, and using Agent Apps.

Walkthrough

This change adds Agent Apps support across mount access, app creation, the HTML app bridge, Drive Run mode, settings, tests, documentation, and Storybook. It also adds ETag-based conditional file operations and folder-scoped app tokens.

Changes

Agent HTML app backend

Layer / File(s) Summary
Scoped mounts and conditional file access
api/entrypoints/routers.py, api/oss/src/apis/fastapi/mounts/*, api/oss/src/core/mounts/*, api/oss/src/core/store/*
Mount responses now include ETags. Writes and deletes support conditional headers. App scope tokens are minted and enforced for mount file access. The mounts service is passed into platform tool dispatch.
App service and platform wiring
api/oss/src/core/apps/*, api/oss/src/core/tools/platform_handlers.py, api/oss/src/core/workflows/*, sdks/python/agenta/sdk/agents/platform/*
App manifests and starters are parsed and discovered. create_app and list_starters are registered as platform operations and added to the build-kit overlay and workflow catalog.
Agent Apps skill and Board starter
api/oss/src/core/apps/skill/*, api/oss/src/core/apps/starters/board@1/*
The Agent Apps skill documents app creation, bridge usage, storage rules, and starter usage. The Board starter adds its manifest, defaults, skill file, and interactive HTML implementation.

HTML app client and UI

Layer / File(s) Summary
Bridge contracts and filesystem client
web/packages/agenta-entities/src/drive/htmlApp/*
The package adds typed bridge messages, path validation, manifest parsing, grants, scope-token caching, ETag caching, filesystem operations, production and mock hosts, and the injected window.agenta stub.
Drive Run mode
web/packages/agenta-entity-ui/src/drive/*, web/packages/agenta-shared/src/state/*, web/oss/src/components/pages/settings/Preferences/*, web/mobile/src/features/settings/*
HTML files gain a feature-gated Run mode. The UI requests access, assembles sandboxed documents, attaches the bridge host, handles navigation and changed files, and exposes the Agent Apps preference.
Kit and document assembly
web/packages/agenta-entity-ui/src/drive/htmlApp/kit/*, web/packages/agenta-entity-ui/scripts/*
The app kit adds theme tokens and CSS. Preview and Run documents use separate assembly paths and CSP policies. A sync script generates the kit CSS module.

Validation and examples

Layer / File(s) Summary
Tests, contracts, and Storybook
api/oss/tests/*, web/packages/*/tests/*, docs/design/agent-html-apps/contracts.md, web/storybook/*
Tests cover app creation, starter discovery, scope tokens, conditional mounts, bridge contracts, hosts, manifests, grants, filesystem operations, Run mode, and document assembly. Contracts and Storybook fixtures describe and exercise the same behavior.

Priority: ➖ Normal

Estimated code review effort: 5 (Critical) | ~120 minutes

Merge Risk: 🟠 High · up to 79936

Agent Apps still has concrete security and data-integrity failure paths that should be corrected before merging, even though the feature is disabled by default.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.06% which is insufficient. The required threshold is 60.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 274 functions across 50 files. (56 skippe… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: enabling users to run agent HTML apps from the drive.
Description check ✅ Passed The description directly explains the agent HTML app feature, its bridge, security controls, conditional writes, starter tools, testing, and known limitations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 16.06% which is insufficient. The required threshold is 60.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 274 functions across 50 files. (56 skipped: 18 unsupported, 38 over the file limit.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ardaerzin

Copy link
Copy Markdown
Contributor Author

Acceptance pass, EE dev stack, /m

Ran the phase 1 acceptance script against the local EE dev stack with the flag on. Fixture: a QA account, an agent, its agent-files mount, and board@1 copied into apps/launch-board/.

# Step Result
1 Open an HTML file from the drive Pass. Source | Preview | Run, breadcrumb agent-files / apps / launch-board / index.html
2 Run in a folder whose app.json says read-write Pass. Grant sheet names the app and dir, "Read and write files" preselected. Viewer-role variant not tested (needs a second member)
3 Grant read-write Pass. Board renders from board.json; strip reads Running · read + write · apps/launch-board
4 Move a card Pass. PUT /mounts/{id}/files?path=apps/launch-board/board.json → 200, Files pane re-lists, server content shows the card moved tododoing with a new etag
5 Reload, reopen the file Pass. Picking Run resumes with no prompt. Note the view itself defaults back to Source; the grant is what persists
6 Agent adds a card, board updates on its own Not run. Needs a real agent turn. useChangedHint reacts to a mountDirQueryFamily refetch, which a drive write or the finished-turn revalidation causes. An out-of-band API write is not a valid proxy and correctly produced nothing
7 Conflict mid-drag Not run. Not drivable from a headless browser. The path is covered by the mock-host unit tests and by the live 412 check below
8 Edit as the agent, then Reload files Pass. Re-reads the folder, no re-prompt
9 Fixture with <script src> and a CDN tag Not run
10 Sibling link and a link outside the folder Not run
11 Read-only grant, then move Pass. Strip reads Running · read, banner "Read-only: changes are not saved", moved card carries unsaved
12 Switch theme Pass. App recolours immediately, no reload, kit accent and badges included
13 New browser session Pass. Asks again after the tab-lived grant is cleared
14 Fresh session, "make me a board" Not run. Needs an LLM-backed agent turn; the QA project has no provider key
15 Flag off, repeat 1 Pass. Source | Preview only, no Run
16 Storybook Drive/HtmlApp Pass for build and coverage: lint clean, build clean, 32 story entries across HtmlAppBody, GrantSheet, RunView, Kit, BoardStarter and the Overview MDX. The a11y runner and the VRT comparison against lane C and E baselines were not run

Also verified

If-Match against the live API, which is what step 7's retry rests on:

READ  apps/launch-board/board.json   -> 200 etag=9835c5ef...
PUT   If-Match: "deadbeef"           -> 412 {"code":"conflict","etag":"9835c5ef..."}
PUT   If-Match: 9835c5ef...          -> 200

api/oss/tests/pytest/unit: 930 passed. @agenta/entities htmlApp.manifest + htmlApp.mockHost: 40 passed.

Gaps a reviewer should know about

  • Only /m was exercised. The desktop host was not.
  • Steps 6, 7 and 14 all need a live agent turn, so the agent-facing half of the feature (create_app, the registry file, the changed push during a turn) has unit coverage but no end-to-end run.
  • Two false alarms during the pass were my fixture, not the code: the starter wants {columns: [{id, title, cards: [{id, text}]}]}, and cards key on text. The starter's SKILL.md documents both correctly, so an agent following it gets it right.

@ardaerzin

Copy link
Copy Markdown
Contributor Author

Width coverage on /m

Clarifying the previous comment: the pass above ran at 1440×900, /m's wide two-pane layout. I have since repeated the core of it at 430×900, the phone layout, and it holds.

  • The Files pane moves behind the header's "Show files pane" control; drilling appslaunch-boardindex.html works, and the file opens with Source | Preview | Run.
  • The grant sheet is responsive: the footer stacks to full-width Run over Cancel instead of sitting side by side, the apps/launch-board chip fits, and the body text wraps.
  • Run with read and write gives Running · read + write · apps/launch-board. Moving a card fires PUT .../files?path=apps/launch-board/board.json → 200.
  • The board itself stacks to one column per section with full-width cards, and the read-only banner sits on the title row. document.documentElement.scrollWidth === clientWidth === 430, so no horizontal scroll at any point.

Still untested: the oss desktop app.

@ardaerzin ardaerzin closed this Sep 19, 2026
The server half landed in the previous commit and nothing used it. The host now
mints a token for its mount, folder and grant level, and all five fs operations
carry it as `X-Agenta-App-Scope` — the four Fern calls through requestOptions,
the axios write beside its If-Match.

The point is that this file stops being the only thing standing between an app
and the rest of the drive. It still resolves the path and checks the grant; the
difference is that a mistake in either is now refused by the API instead of
served. That is what the popup and WebRTC fixes could not buy: those closed two
exits, and the exit list never finishes.

A failure to mint returns null and the app runs unscoped. The token only ever
narrows, so its absence is exactly the behaviour that shipped before it existed
— refusing to open an app because a token endpoint is missing would trade a
defence-in-depth layer for an outage.

One mint per mount+dir+level, shared across a burst of parallel boot reads, and
re-minted a minute before expiry so a long-open app never lands on a lapsed
token.

The mint call uses axios because the endpoint postdates the last Fern
generation. It belongs in the same regeneration that gives `writeMountFile` a
body; both are why this module touches axios at all, and both close together.
…al was wrong

The recorded decision was "no server-side folder check in v1, by design; a
scoped token if apps ever become shareable". The reasoning behind it was that an
escape had no payoff because the app had no way to get data out.

That was false twice. Popups went out through a channel no CSP directive
governs; WebRTC went out through another. Both were found by thinking of one
more exit, which is not a method that finishes — the surface grows every browser
release, and a blocklist is only as good as the last person who looked.

So the deferral is withdrawn rather than left standing as the decision, and the
egress table now says what it is: a record of what is closed, never a proof that
nothing is open.

The table gains the WebRTC row and the measurement behind it. The section gains
the reason the prefix check exists: an app that cannot obtain bytes outside its
folder makes the exit list stop being load-bearing. The table still matters,
because an app can always leak the folder it was granted — the difference is
that the damage is now bounded by what the person chose to show it, rather than
by whether the page got every path right.
…ite included

Mahmoud's question 3 asked which mounts client methods should back the bridge,
and his own answer ruled out the workaround we shipped: "Existing binary
download code uses Axios because Fern JSON parsing cannot preserve bytes; that
exception does not automatically apply to new text file methods."

The reason it happened anyway was real. `write_mount_file` reads its body with
`await request.body()`, which FastAPI cannot see, so the spec described a PUT
with no body and the generated method duly sent none — calling it would have
written an empty file. The scope-token endpoint then added a second axios call
for the same reason.

Both are closed here. The route declares its body for OpenAPI only, through
`openapi_extra`: the runtime read is untouched, so behaviour is byte-identical
and the 255 mount tests are unchanged. With the body in the spec, Fern emits a
method that carries it, and `write` and the token mint both move onto the
generated client. No hand-built URLs are left in the bridge.

Two things a reader should know. Fern drops declared header params from an
endpoint that takes a binary body, so `If-Match` travels in
`requestOptions.headers` on write while staying a typed field on delete. And
the regeneration is deliberately narrowed to the mounts resource, per the build
plan: a full regen also pulls in unrelated drift, and `GatewayPermissions`
turning optional breaks the DrillIn schema controls. That client is stale and
deserves its own regeneration with the consumers fixed — not a ride-along here.
Preview strips every agent script and then renders with allow-popups, and
the first half was being read as the reason the second half was safe.

It is not. The stripper clears iframe[srcdoc] but never touched
iframe[src="data:text/html,..."]. That nested context inherits
allow-scripts from the preview sandbox, so its script runs even though the
outer document has none left, and Preview injected no CSP at all, so it
could fetch anywhere. Verified against the real flags: the request arrived
at a listening server with its query string intact. object[data] and embed
execute the same way, so adding iframe[src] to the strip list would have
moved the vector rather than closed it.

PREVIEW_CSP denies the capability instead of chasing the elements:
frame-src and object-src close the nested contexts, and default-src 'none'
covers connect-src so a context that somehow runs still has no way out. Re-run
of the same probe with the policy in place: nothing ran, nothing arrived.

Wider than RUN_CSP in one place on purpose. inlineAssets only folds in
same-mount assets and leaves external URLs alone, so drive HTML that links a
remote stylesheet, image or font renders today and keeps rendering.

The move-lock test now compares through withoutCsp, so it still guards the
inlining byte-for-byte while the policy is asserted on its own.
The egress section described Run and never said so, so 'no allow-popups'
read as global. Preview keeps both popup flags, and what was standing in
for them was the script stripper - which never covered a data: iframe.

Names the vector, the measurement, and why PREVIEW_CSP denies the
capability rather than lengthening the strip list. Also states the cost:
a page embedding a legitimate iframe no longer renders that frame in
Preview.
@ardaerzin

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (2)
web/packages/agenta-entities/tests/unit/htmlApp.host.test.ts (1)

732-744: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Run the window-message probe in a browser-like environment.

The package defaults to the node environment, so window is undefined and the guard skips window.postMessage. The test then checks an empty call list without exercising window-message rejection. Add the jsdom pragma or remove the guard so the test fails if window is unavailable.

♻️ Proposed fix
+// `@vitest-environment` jsdom
 /**
  * The real host against a fake fs client. Part 1 is the SAME behavioural table as
-        if (typeof window !== "undefined") {
-            window.postMessage({v: 1, id: 1, method: "read", path: "index.html"}, "*")
-        }
+        window.postMessage({v: 1, id: 1, method: "read", path: "index.html"}, "*")

Source: Linters/SAST tools

web/packages/agenta-entities/tests/unit/htmlApp.egress.test.ts (1)

64-73: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the emitted neutralization, not just the identifier.

BRIDGE_STUB contains each identifier in RTC_GLOBALS and comments, so toContain(name) does not prove that the stub neutralizes it. The normal path uses Object.defineProperty(window, RTC_GLOBALS[r], ...); delete is only a fallback and uses a dynamic index, so the proposed deletion regex does not match the emitted implementation.

♻️ Proposed stricter assertion
-    it("removes every peer-connection constructor in the stub", () => {
+    it("neutralizes every peer-connection constructor in the stub", () => {
         for (const name of [
             "RTCPeerConnection",
             "webkitRTCPeerConnection",
             "mozRTCPeerConnection",
             "RTCDataChannel",
         ]) {
-            expect(BRIDGE_STUB).toContain(name)
+            expect(BRIDGE_STUB).toMatch(
+                new RegExp(
+                    `var RTC_GLOBALS = \\[[\\s\\S]*"${name}"[\\s\\S]*\\][\\s\\S]*` +
+                        `Object\\.defineProperty\\(window, RTC_GLOBALS\\[r\\], \\{[\\s\\S]*` +
+                        `value: undefined,[\\s\\S]*configurable: false,[\\s\\S]*writable: false,`,
+                ),
+            )
         }

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Advanced

Run ID: 90515dc5-b990-43d6-abf4-0f90729f5dc9

📥 Commits

Reviewing files that changed from the base of the PR and between d07b67f and 8ec390c.

⛔ Files ignored due to path filters (9)
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/client/Client.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/client/requests/AppScopeRequest.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/client/requests/DeleteMountFileRequest.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/client/requests/GetMountFilesRequest.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/client/requests/WriteMountFileRequest.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/client/requests/index.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/types/AppScopeResponse.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/types/MountFileWrittenResponse.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/types/index.ts is excluded by !**/generated/**
📒 Files selected for processing (106)
  • api/entrypoints/routers.py
  • api/oss/src/apis/fastapi/mounts/models.py
  • api/oss/src/apis/fastapi/mounts/router.py
  • api/oss/src/apis/fastapi/tools/router.py
  • api/oss/src/core/apps/__init__.py
  • api/oss/src/core/apps/assembly.py
  • api/oss/src/core/apps/handlers.py
  • api/oss/src/core/apps/scope_token.py
  • api/oss/src/core/apps/service.py
  • api/oss/src/core/apps/skill/SKILL.md
  • api/oss/src/core/apps/skill/sections/01-when.md
  • api/oss/src/core/apps/skill/sections/02-first.md
  • api/oss/src/core/apps/skill/sections/03-folder.md
  • api/oss/src/core/apps/skill/sections/04-bridge.md
  • api/oss/src/core/apps/skill/sections/05-custom-rules.md
  • api/oss/src/core/apps/skill/sections/06-starters.md
  • api/oss/src/core/apps/skill/sections/07-after.md
  • api/oss/src/core/apps/skill/sections/08-agent-level.md
  • api/oss/src/core/apps/starters/board@1/SKILL.md
  • api/oss/src/core/apps/starters/board@1/app.json
  • api/oss/src/core/apps/starters/board@1/config.defaults.json
  • api/oss/src/core/apps/starters/board@1/index.html
  • api/oss/src/core/mounts/dtos.py
  • api/oss/src/core/mounts/service.py
  • api/oss/src/core/mounts/types.py
  • api/oss/src/core/store/dtos.py
  • api/oss/src/core/store/storage.py
  • api/oss/src/core/store/types.py
  • api/oss/src/core/tools/platform_handlers.py
  • api/oss/src/core/workflows/build_kit.py
  • api/oss/src/core/workflows/static_catalog.py
  • api/oss/tests/pytest/unit/applications/test_build_kit_overlay.py
  • api/oss/tests/pytest/unit/apps/__init__.py
  • api/oss/tests/pytest/unit/apps/conftest.py
  • api/oss/tests/pytest/unit/apps/test_create_app.py
  • api/oss/tests/pytest/unit/apps/test_list_starters.py
  • api/oss/tests/pytest/unit/apps/test_op_catalog_parity.py
  • api/oss/tests/pytest/unit/apps/test_scope_token.py
  • api/oss/tests/pytest/unit/apps/test_skill_assembly.py
  • api/oss/tests/pytest/unit/apps/test_starter_manifest.py
  • api/oss/tests/pytest/unit/mounts/test_mount_file_conditional_routes.py
  • api/oss/tests/pytest/unit/mounts/test_protected_mount_policy.py
  • api/oss/tests/pytest/unit/mounts/test_store_conditional_ops.py
  • api/oss/tests/pytest/unit/skills/test_registry_listing.py
  • api/oss/tests/pytest/unit/test_mounts_file_ops.py
  • api/oss/tests/pytest/unit/workflows/test_static_catalog.py
  • docs/design/agent-html-apps/contracts.md
  • sdks/python/agenta/sdk/agents/platform/op_catalog.py
  • sdks/python/agenta/sdk/agents/platform_instructions.py
  • sdks/python/oss/tests/pytest/unit/agents/golden/run_request.gateway_connection.json
  • sdks/python/oss/tests/pytest/unit/agents/platform/test_op_catalog.py
  • sdks/python/oss/tests/pytest/unit/agents/test_platform_instructions.py
  • web/mobile/src/features/settings/PreferencesTab.tsx
  • web/oss/src/components/pages/settings/Preferences/Preferences.tsx
  • web/packages/agenta-entities/src/drive/htmlApp/etags.ts
  • web/packages/agenta-entities/src/drive/htmlApp/fsClient.ts
  • web/packages/agenta-entities/src/drive/htmlApp/grants.ts
  • web/packages/agenta-entities/src/drive/htmlApp/host.ts
  • web/packages/agenta-entities/src/drive/htmlApp/index.ts
  • web/packages/agenta-entities/src/drive/htmlApp/manifest.ts
  • web/packages/agenta-entities/src/drive/htmlApp/mockHost.ts
  • web/packages/agenta-entities/src/drive/htmlApp/protocol.ts
  • web/packages/agenta-entities/src/drive/htmlApp/scope.ts
  • web/packages/agenta-entities/src/drive/htmlApp/scopeToken.ts
  • web/packages/agenta-entities/src/drive/htmlApp/stub.ts
  • web/packages/agenta-entities/src/drive/index.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.egress.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.etags.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.fsClient.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.grants.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.host.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.manifest.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.mockHost.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.scope.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.scopeToken.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.stub.test.ts
  • web/packages/agenta-entity-ui/package.json
  • web/packages/agenta-entity-ui/scripts/sync-kit-css.mjs
  • web/packages/agenta-entity-ui/src/drive/DriveExplorer.tsx
  • web/packages/agenta-entity-ui/src/drive/htmlApp/GrantSheet.tsx
  • web/packages/agenta-entity-ui/src/drive/htmlApp/HtmlAppBody.tsx
  • web/packages/agenta-entity-ui/src/drive/htmlApp/RunView.tsx
  • web/packages/agenta-entity-ui/src/drive/htmlApp/assemble.ts
  • web/packages/agenta-entity-ui/src/drive/htmlApp/index.ts
  • web/packages/agenta-entity-ui/src/drive/htmlApp/kit/agenta-app.css
  • web/packages/agenta-entity-ui/src/drive/htmlApp/kit/index.ts
  • web/packages/agenta-entity-ui/src/drive/htmlApp/kit/kitCss.ts
  • web/packages/agenta-entity-ui/src/drive/htmlApp/kit/tokens.ts
  • web/packages/agenta-entity-ui/src/drive/htmlApp/useAppManifest.ts
  • web/packages/agenta-entity-ui/src/drive/htmlApp/useChangedHint.ts
  • web/packages/agenta-entity-ui/src/drive/index.ts
  • web/packages/agenta-entity-ui/src/drive/renderers.tsx
  • web/packages/agenta-entity-ui/tests/unit/htmlApp.assemble.test.ts
  • web/packages/agenta-entity-ui/tests/unit/htmlApp.grantEscalation.test.tsx
  • web/packages/agenta-entity-ui/tests/unit/htmlApp.kit.test.ts
  • web/packages/agenta-shared/src/state/featureFlags.ts
  • web/packages/agenta-shared/src/state/index.ts
  • web/storybook/fixtures/boardStarter.ts
  • web/storybook/fixtures/htmlApp.ts
  • web/storybook/raw.d.ts
  • web/storybook/stories/entity-ui/htmlApp/BoardStarter.stories.tsx
  • web/storybook/stories/entity-ui/htmlApp/GrantSheet.stories.tsx
  • web/storybook/stories/entity-ui/htmlApp/HtmlApp.mdx
  • web/storybook/stories/entity-ui/htmlApp/HtmlAppBody.stories.tsx
  • web/storybook/stories/entity-ui/htmlApp/Kit.stories.tsx
  • web/storybook/stories/entity-ui/htmlApp/RunView.stories.tsx

Included review availability: Your plan provides up to 8 included reviews per hour; 6 remain after this review.

Comment on lines +640 to +649
x_agenta_app_scope: Optional[str] = Header(default=None),
):
await self._check(request, Permission.VIEW_MOUNTS)
enforce_app_scope(
token=x_agenta_app_scope,
project_id=UUID(request.state.project_id),
mount_id=mount_id,
path=read if read is not None else path,
writing=False,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '620,675p' api/oss/src/apis/fastapi/mounts/router.py
sed -n '150,185p' api/oss/src/core/apps/scope_token.py
rg -n "get_mount_files|apps/scope|X-Agenta-App-Scope|pathless|scope.*list|folder-scoped" api/oss/tests/pytest/unit/mounts api/oss/src/core/apps docs/design/agent-html-apps/contracts.md web/packages/agenta-entities/src/drive/htmlApp

Repository: Agenta-AI/agenta

Length of output: 4156


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- route and authorization bindings ---'
rg -n "def _check|def get_mount_files|list_files\(" api/oss/src/apis/fastapi/mounts/router.py api/oss/src/core api/oss/src/dbs | head -80
sed -n '620,710p' api/oss/src/apis/fastapi/mounts/router.py
printf '%s\n' '--- scope helper ---'
cat -n api/oss/src/core/apps/scope_token.py | sed -n '1,230p'
printf '%s\n' '--- documented contract ---'
sed -n '220,310p' docs/design/agent-html-apps/contracts.md
printf '%s\n' '--- frontend scope/list contract ---'
cat -n web/packages/agenta-entities/src/drive/htmlApp/fsClient.ts | sed -n '1,130p'
cat -n web/packages/agenta-entities/src/drive/htmlApp/scope.ts | sed -n '1,130p'
printf '%s\n' '--- route tests and list implementation references ---'
rg -n -C 4 "get_mount_files|list_files|path.*None|allowRoot|X-Agenta-App-Scope|ScopeToken" api/oss/tests api/oss/src/core/mounts api/oss/src/apis/fastapi/mounts docs/design/agent-html-apps/contracts.md

Repository: Agenta-AI/agenta

Length of output: 50374


🏁 Script executed:

set -e
sed -n '640,705p' api/oss/src/apis/fastapi/mounts/router.py
rg -n -C 5 "def _check|def list_files|async def list_files|allowRoot|X-Agenta-App-Scope|folder-scoped|path is None|scope" api/oss/src/apis/fastapi/mounts api/oss/src/core api/oss/tests docs/design/agent-html-apps/contracts.md web/packages/agenta-entities/src/drive/htmlApp

Repository: Agenta-AI/agenta

Length of output: 50373


🏁 Script executed:

set -e
printf '%s\n' '--- authorization ---'
sed -n '345,375p' api/oss/src/apis/fastapi/mounts/router.py
printf '%s\n' '--- list service contract and path flow ---'
sed -n '1044,1165p' api/oss/src/core/mounts/service.py
printf '%s\n' '--- route registration ---'
sed -n '300,323p' api/oss/src/apis/fastapi/mounts/router.py
printf '%s\n' '--- focused no-path tests ---'
sed -n '640,670p' api/oss/tests/pytest/unit/test_mounts_file_ops.py
sed -n '820,838p' api/oss/tests/pytest/unit/test_mounts_file_ops.py

Repository: Agenta-AI/agenta

Length of output: 10861


Constrain pathless listings to the app token prefix. A caller must pass the normal VIEW_MOUNTS check, but it can omit both path and read. The route then passes path=None to enforce_app_scope, which skips the prefix check, and to list_files, which returns the whole mount tree. Resolve the token prefix as the effective path when a scoped token has no query path, and pass that path to both operations. Requests without an app token must retain their existing project-permission behavior.

`version`, stop and say so. Do not guess at a migration.
2. **After every app event in this session** (create, update, archive): rewrite
`agent-files/.apps/registry/<session_id>.json` whole, as
`{"version": 1, "session_id": "<id>", "apps": [{"slug", "path", "template", "created_at",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use valid JSON in every persisted-file example. The current examples use bare object keys. An agent can copy them into drive files, causing later JSON parsing to fail.

  • api/oss/src/core/apps/skill/sections/08-agent-level.md#L12-L12: quote registry keys and use valid placeholder values.
  • api/oss/src/core/apps/skill/SKILL.md#L71-L71: keep the aggregate registry example valid and identical to the source section.
  • api/oss/src/core/apps/starters/board@1/SKILL.md#L12-L12: quote all board.json keys and show valid JSON syntax.
📍 Affects 3 files
  • api/oss/src/core/apps/skill/sections/08-agent-level.md#L12-L12 (this comment)
  • api/oss/src/core/apps/skill/SKILL.md#L71-L71
  • api/oss/src/core/apps/starters/board@1/SKILL.md#L12-L12


function normaliseConfig(raw) {
const cols = raw && Array.isArray(raw.columns)
? raw.columns.filter((c) => c && typeof c.id === "string")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject duplicate column IDs before normalizing the board.

normaliseConfig() accepts duplicate IDs. normalise() then stores columns in a Map keyed by ID, which keeps only the last duplicate. If users add cards to both rendered duplicate columns, the next load drops one column's cards and a later save persists that loss. Reject duplicate IDs before calling normalise().

const paths = (msg && msg.paths) || [];
if (!paths.some((p) => p === BOARD_FILE || p === CONFIG_FILE)) return;
// Our own pending write will merge on conflict; a re-read now would drop local edits.
if (drag || saving || dirty) { pendingReload = true; return; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
set -eu
file='api/oss/src/core/apps/starters/board@1/index.html'
printf '%s\n' '--- relevant source ---'
sed -n '240,350p' "$file"
printf '%s\n' '--- matching symbols ---'
rg -n -C 4 'scheduleSave|pendingReload|dirty|read.?only|changed|reload' "$file"

Repository: Agenta-AI/agenta

Length of output: 9763


Handle pending reloads after read-only edits.

When agenta.canWrite is false, scheduleSave() sets dirty and returns without scheduling save(). A later changed event sets pendingReload, but both deferred reload checks require !dirty. Remote changes therefore remain stale until a full reload. Add a read-only discard/reload or reconciliation path.

Comment on lines +148 to +152
Frontend transport note: the generated Fern `writeMountFile` sends no body, so the host writes
through the existing axios raw-body path with `Content-Type: text/plain; charset=utf-8` and an
`If-Match` header. Reads, list and delete go through the Fern client; delete passes `If-Match`
as the generated request's typed `if-match` field. Either way the 412 body above is what the host
maps to `conflict`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the transport note; it contradicts the implementation.

The note says the generated writeMountFile sends no body and the host writes through an axios raw-body path. fsClient.ts now sends the write through getMountsClient().writeMountFile(...), because the route declares the body for OpenAPI. If-Match and the scope token travel in requestOptions.headers for that one call.

Correct this paragraph so the lanes reading it do not restore the axios path.

Comment on lines +25 to +27
import {scopeHeaders} from "./scopeToken"

import {safeParseWithLogging} from "../../shared/utils/zodSchema"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the import order; CI fails on it.

ESLint import/order requires ./scopeToken after ./protocol. The lint job runs with --max-warnings 0, so the build fails.

🔧 Proposed import order
-import {scopeHeaders} from "./scopeToken"
-
 import {safeParseWithLogging} from "../../shared/utils/zodSchema"
 
 import {
     READ_CAP,
     WRITE_CAP,
     type BridgeErrorCode,
     type FileEntry,
     type FileStat,
     type FsResults,
 } from "./protocol"
+import {scopeHeaders} from "./scopeToken"
🧰 Tools
🪛 GitHub Actions: 11 - check code styling / 3_TypeScript lint.txt

[error] 25-25: ESLint import/order: './scopeToken' import should occur after the import of './protocol'. Command failed during eslint --config ../eslint.config.mjs src/ --max-warnings 0.

🪛 GitHub Actions: 11 - check code styling / TypeScript lint

[error] 25-25: ESLint import/order: './scopeToken' import should occur after './protocol'. Command failed: eslint --config ../eslint.config.mjs src/ --max-warnings 0.

🪛 GitHub Check: TypeScript lint

[failure] 25-25:
./scopeToken import should occur after import of ./protocol

Source: Pipeline failures

Comment on lines +132 to +133
const [currentPath, setCurrentPath] = useState(entryPath)
const [backStack, setBackStack] = useState<string[]>([])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reset the page state when entryPath changes.

currentPath and backStack are initialised once. When the caller swaps the entry file while Run stays selected, entryPath changes but currentPath keeps the previous path. The assemble effect then takes the io.fetchText(currentPath) branch and renders the previous file, and the strip shows a stale page label.

Synchronise both on an entryPath change.

🔧 Proposed fix
     const [currentPath, setCurrentPath] = useState(entryPath)
     const [backStack, setBackStack] = useState<string[]>([])

Add after the state declarations:

// A new entry file is a new app page: drop the in-app navigation state.
useEffect(() => {
    setCurrentPath(entryPath)
    setBackStack([])
    setErrors([])
}, [entryPath])

ardaerzin and others added 5 commits September 20, 2026 13:12
The registry layout is already per session, so concurrent sessions cannot
overwrite each other. What was missing is the read side: rule 3 told the
agent to merge every session's file by slug without saying what merge means
when two of them claim the same slug, which is the one case that can happen.

Later updated_at wins, this session breaks a tie, and the merge stays a read
operation. The last part matters more than the tiebreak: without it an agent
could decide to tidy up by rewriting another session's file, which rule 2
forbids and which would lose that session's record.

Regenerated SKILL.md from the section, per assembly.py.
…dicts

The folder section told the agent that each file has one writer and the app
owns its data while open. board@1's own SKILL.md says the opposite in the
same breath: 'the app saves after every move; the agent may edit board.json
too, the app re-reads on change.' The whole If-Match and conflict path
exists because two writers share that file.

So the rule was never one writer. It is one primary writer plus
reconciliation, and an agent reading the old sentence would either avoid a
write it is supposed to make or make it without reading first, which is the
unconditional write that loses data.

Says what the agent needs instead: it may write data, and it reads the file
first because its writes carry no If-Match. Kept terse because the
instruction body is budgeted; 4584 of 4608 bytes after this.
…s write

Two stale claims in the contract that reviewers would take at face value.

DELETE was listed as honouring If-Match with no qualification. It does, but
the store emulates it as stat-compare-delete because conditional
DeleteObject is not portable, so a write landing in the window is not
caught. PUT has no such gap. storage.py already says this at the function;
the contract is where someone reading the API would look, and it did not.

The transport note still said the generated writeMountFile sends no body and
the host writes through axios. That stopped being true in 2d16e16: the
endpoint declares its raw body through openapi_extra and every method now
goes through the generated client.
Review on #6972.

A pathless listing skipped the prefix check. `enforce` only compared a path when
one was given, and the list route passed the caller's `None` straight through to
`list_files`, which walks the whole mount. So a page holding a folder-scoped
token could read every file on the drive by leaving `path` off — the one thing a
scope token must never permit.

`enforce` now returns the path the request must use, so narrowing cannot be
forgotten at a call site: a scoped caller that names no path gets its own folder,
and an unscoped one keeps whatever it asked for.

Also from the same review:

- The board starter treated a column id as an identity everywhere except where it
  read one. Duplicate ids in the config rendered a second column that always drew
  empty, and duplicates in the stored board collapsed in a Map that kept only the
  last, dropping the other's cards on the next load. Config now keeps one column
  per id, and a stored duplicate folds its cards in rather than losing them.
- The Run view is keyed by its entry file. It held the page, the back stack and
  the iframe from whichever file it opened with, so swapping the entry while Run
  stayed selected re-rendered the previous file under the new app's name.
- The session registry example was not valid JSON. Agents copy these literally,
  so it names its keys instead of showing a shape that cannot be parsed.
- Import order in `fsClient.ts`, and `ruff format` on the mounts router: both
  were failing CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review on #6972. `dirty` was standing in for two different things: "a save is
coming" on a writable board, and "scratch edits that can never be written" on a
read-only one. The reload guards wanted the first meaning and got the second, so
once a read-only viewer touched a card the board never picked up a remote change
again — `scheduleSave` set `dirty` and returned without ever scheduling the save
that clears it, and every `pendingReload` check required `!dirty`.

One named predicate now asks the real question: local edits hold a reload back
only while something will actually write them. The viewer is already told their
changes are not saved, so a remote change wins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ardaerzin

Copy link
Copy Markdown
Contributor Author

Worked through the review. Both failing checks are green locally against the CI commands, and every finding was checked against the code before acting — two of them turned out to be already fixed on the branch.

The security finding — real, and worse than the listing

Confirmed. enforce only compared a path when one was given, so a pathless call skipped the prefix check entirely and the route handed the caller's None to list_files, which walks the whole mount.

Rather than special-casing the listing, enforce now returns the path the request must use. Narrowing stops being something a call site can forget: a scoped caller that names no path gets its own folder, an unscoped one keeps what it asked for. The write and delete routes already pass a required path, so they were never exposed.

Verified against the running API, not just in tests:

request files returned
no token, no path 43, across the whole mount
token scoped to agents/, no path 41, all under agents/, none outside

The new unit test fails with the fix reverted.

The board's duplicate column ids — data loss, reproduced

Confirmed, and the loss is concrete. Running the shipped functions before and after, with a config that repeats todo and a stored board carrying two todo columns:

before: config ids [todo, todo, done] → todo:[c2], todo:[], done:[c3]   card c1 lost
after:  config ids [todo, done]       → todo:[c1,c2], done:[c3]         all cards kept

The id is the column's identity everywhere except where it was read. Config now keeps one column per id, and a stored duplicate folds its cards in instead of the Map silently keeping the last.

One more of the same kind, found while checking that file

dirty was standing in for two things: "a save is coming" when writable, and "scratch edits that can never be written" when read-only. The reload guards wanted the first and got the second, so once a read-only viewer touched a card, scheduleSave set dirty and returned without scheduling the save that clears it — and every pendingReload check required !dirty. The board then never took another remote change. One named predicate now asks the real question.

The rest

  • RunView kept the page, the back stack and the iframe from whichever file it opened with. Keyed by the entry file, so all of that resets together rather than only the two fields we happened to think of.
  • Session registry example was not valid JSON. The instruction body has 24 bytes of headroom, so it names its keys instead of showing a shape that cannot be parsed, and I trimmed a restated sentence to fit. SKILL.md regenerated.
  • Import order and ruff format: both were the failing checks. pnpm run lint and ruff format --check are clean.

Already fixed, no change made

The contracts.md transport note was corrected in b907e49, before this review ran — it already says every method goes through the Fern client and that the axios workaround is gone. The matching note in fsClient.ts is accurate too.

Checks

oss/tests/pytest/unit/apps 87 passed · test_mounts_file_ops 65 passed · @agenta/entity-ui 832 passed · @agenta/entities 1927 passed · pnpm run lint clean · ruff format --check and ruff check clean.

What is not QA'd in the browser

The scope fix and the board fix are verified as above. The RunView key is not: no drive in this project holds an HTML app, so Run mode has nothing to open without building one first. It is covered by the package suites and by the fact that a changed key remounts.

Two seams the server had already built and the bridge never used, both raised by
the review on #6983 as documentation problems.

The server refuses a path outside the folder and a write above the level with
the same 403, and only `detail.code` separates them. Every 403 was mapped to
`read_only`, so the SERVER-side folder boundary — the one that matters, the one
a page cannot talk its way past — reported "read-only access". It also disagreed
with the host's own path guard, which calls the same condition `scope`.

The write precondition had the matching gap. `If-None-Match: *` is implemented
in the API and named in the contract, but `FsWriteOptions` had no way to send
it, so a write with no cached etag went out unconditional: two Run sessions
could both create the same path and the later one overwrote the earlier, with
no 412. A first write is now create-only, and a path that already exists comes
back as `conflict` for the app to read and merge. A held etag still wins — it
is the stronger statement, and the two together would contradict each other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ardaerzin
ardaerzin changed the base branch from release/v0.119.0 to release/v0.119.1 September 21, 2026 12:53
@ardaerzin
ardaerzin marked this pull request as ready for review September 21, 2026 13:45
@github-actions

github-actions Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

📘 Docs preview

Status ✅ Ready
Preview https://pr-6972-agenta-docs-preview.mahmoud-637.workers.dev/docs
Inspect Actions run
Commit 79936b3ac45b090208b28e50998fd46a567b8d2b

This comment updates in place on every push.

@ardaerzin

Copy link
Copy Markdown
Contributor Author

Marked ready. Re-ran the acceptance script against the current branch on the EE dev stack, and closed several of the gaps from the earlier pass. One lane is genuinely unbuilt and a reviewer should know before approving.

Re-verified on this branch

Created board@1 in an agent drive at agent-files/apps/launch-board/ (app.json, index.html, config.json, board.json), opened it from the drive and picked Run:

  • 1 Source | Preview | Run on an HTML file in the drive. Pass.
  • 2 Grant sheet names the app and the folder: "Run Board? This app can use files in agent-files/apps/launch-board", with read and read-write offered. Pass.
  • 3 Granting read-write runs it: strip reads Running · read + write · apps/launch-board, iframe attached. Pass.

Plus, from the review round on this PR, all verified against the live API rather than in tests alone:

  • A scoped token can no longer widen a listing. Unscoped pathless returns 43 files across the mount; scoped to a folder returns only that folder's 41.
  • 403 {"code":"scope"} now reaches the bridge as scope rather than read_only.
  • A first write is create-only: 200 then 412 {"code":"conflict","etag":…} on the same path.
  • board@1 no longer loses cards to duplicate column ids, and a read-only board no longer freezes after the first touch.

The lane that is not built: skill attachment

Step 14 ("fresh session, make me a board") cannot pass, and the cause is not the fixture. Asked a drive-bearing agent for a board and it answered that it has no create_app tool.

Both halves exist — agenta-apps is in the static catalogue (static_catalog.py:341) and create_app / list_starters are registered handlers (platform_handlers.py:995) — but nothing attaches the skill to an agent:

  • There is no default-skill hook. Skills live in parameters.agent.skills[] on the agent revision, and nothing seeds that list.
  • It cannot be attached by hand either. The Add-skills picker lists registry skills; a first-party static catalogue entry does not appear there, so a user cannot work around it.

This is the item phase1-run-mode.html now puts inside phase 1 rather than deferring, because the phase goal and step 14 both start from "an agent that has the skill". Until it lands, the agent half of the feature is unreachable: the browser half runs, but nothing can create an app except by writing the files directly, which is what I did to test the rest.

Still not verified

  • 6 (agent adds a card, board updates by itself) — blocked on the same thing: needs an agent turn that can write into the app folder.
  • 7 (conflict mid-drag) — not drivable from the browser harness; covered by the mock-host unit tests and by the live 412 above.
  • 9, 10 (<script src> / CDN tag; sibling and out-of-folder links) — not run this pass.
  • RunView keyed by its entry file — the fix is in and unit-tested, but I could not exercise the file swap in Run mode through the tree UI in this harness. The behaviour is a React remount on a changed key.
  • 16 — Storybook builds and the stories are present; the a11y runner and the VRT comparison were not run.
  • 2 viewer-role variant still needs a second member on the project.

@github-actions

github-actions Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Website preview

Preview URL: https://pr-6972-agenta-website-preview.mahmoud-637.workers.dev

Built from 79936b3ac45b090208b28e50998fd46a567b8d2b. This comment updates in place on every push.

@github-actions

github-actions Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Railway Preview Environment

Status Destroyed (PR closed)

Updated at 2026-09-22T14:13:43.086Z

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🧹 Nitpick comments (1)
api/oss/tests/pytest/unit/apps/test_scope_token.py (1)

97-107: 🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | 🔵 Trivial | ⚡ Quick win

Path Traversal

Reachability: External
Exploitability: Theoretical
CWE: CWE-22 — Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

Reject dot-segment paths at the scope boundary. allows_path accepts apps/../secrets.json because it compares raw strings. The three production scope-enforced operations reject literal .. segments in validate_file_path before filesystem access, so this is defense in depth rather than an established traversal. Add prefixed traversal cases and reject dot segments before the scope comparison. Encoded-separator cases require a separate request-decoding contract.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Advanced

Run ID: 25274572-beed-423b-aeaf-b614bf251154

📥 Commits

Reviewing files that changed from the base of the PR and between 8ec390c and 8210d86.

⛔ Files ignored due to path filters (9)
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/client/Client.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/client/requests/AppScopeRequest.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/client/requests/DeleteMountFileRequest.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/client/requests/GetMountFilesRequest.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/client/requests/WriteMountFileRequest.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/client/requests/index.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/types/AppScopeResponse.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/types/MountFileWrittenResponse.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/types/index.ts is excluded by !**/generated/**
📒 Files selected for processing (106)
  • api/entrypoints/routers.py
  • api/oss/src/apis/fastapi/mounts/models.py
  • api/oss/src/apis/fastapi/mounts/router.py
  • api/oss/src/apis/fastapi/tools/router.py
  • api/oss/src/core/apps/__init__.py
  • api/oss/src/core/apps/assembly.py
  • api/oss/src/core/apps/handlers.py
  • api/oss/src/core/apps/scope_token.py
  • api/oss/src/core/apps/service.py
  • api/oss/src/core/apps/skill/SKILL.md
  • api/oss/src/core/apps/skill/sections/01-when.md
  • api/oss/src/core/apps/skill/sections/02-first.md
  • api/oss/src/core/apps/skill/sections/03-folder.md
  • api/oss/src/core/apps/skill/sections/04-bridge.md
  • api/oss/src/core/apps/skill/sections/05-custom-rules.md
  • api/oss/src/core/apps/skill/sections/06-starters.md
  • api/oss/src/core/apps/skill/sections/07-after.md
  • api/oss/src/core/apps/skill/sections/08-agent-level.md
  • api/oss/src/core/apps/starters/board@1/SKILL.md
  • api/oss/src/core/apps/starters/board@1/app.json
  • api/oss/src/core/apps/starters/board@1/config.defaults.json
  • api/oss/src/core/apps/starters/board@1/index.html
  • api/oss/src/core/mounts/dtos.py
  • api/oss/src/core/mounts/service.py
  • api/oss/src/core/mounts/types.py
  • api/oss/src/core/store/dtos.py
  • api/oss/src/core/store/storage.py
  • api/oss/src/core/store/types.py
  • api/oss/src/core/tools/platform_handlers.py
  • api/oss/src/core/workflows/build_kit.py
  • api/oss/src/core/workflows/static_catalog.py
  • api/oss/tests/pytest/unit/applications/test_build_kit_overlay.py
  • api/oss/tests/pytest/unit/apps/__init__.py
  • api/oss/tests/pytest/unit/apps/conftest.py
  • api/oss/tests/pytest/unit/apps/test_create_app.py
  • api/oss/tests/pytest/unit/apps/test_list_starters.py
  • api/oss/tests/pytest/unit/apps/test_op_catalog_parity.py
  • api/oss/tests/pytest/unit/apps/test_scope_token.py
  • api/oss/tests/pytest/unit/apps/test_skill_assembly.py
  • api/oss/tests/pytest/unit/apps/test_starter_manifest.py
  • api/oss/tests/pytest/unit/mounts/test_mount_file_conditional_routes.py
  • api/oss/tests/pytest/unit/mounts/test_protected_mount_policy.py
  • api/oss/tests/pytest/unit/mounts/test_store_conditional_ops.py
  • api/oss/tests/pytest/unit/skills/test_registry_listing.py
  • api/oss/tests/pytest/unit/test_mounts_file_ops.py
  • api/oss/tests/pytest/unit/workflows/test_static_catalog.py
  • docs/design/agent-html-apps/contracts.md
  • sdks/python/agenta/sdk/agents/platform/op_catalog.py
  • sdks/python/agenta/sdk/agents/platform_instructions.py
  • sdks/python/oss/tests/pytest/unit/agents/golden/run_request.gateway_connection.json
  • sdks/python/oss/tests/pytest/unit/agents/platform/test_op_catalog.py
  • sdks/python/oss/tests/pytest/unit/agents/test_platform_instructions.py
  • web/mobile/src/features/settings/PreferencesTab.tsx
  • web/oss/src/components/pages/settings/Preferences/Preferences.tsx
  • web/packages/agenta-entities/src/drive/htmlApp/etags.ts
  • web/packages/agenta-entities/src/drive/htmlApp/fsClient.ts
  • web/packages/agenta-entities/src/drive/htmlApp/grants.ts
  • web/packages/agenta-entities/src/drive/htmlApp/host.ts
  • web/packages/agenta-entities/src/drive/htmlApp/index.ts
  • web/packages/agenta-entities/src/drive/htmlApp/manifest.ts
  • web/packages/agenta-entities/src/drive/htmlApp/mockHost.ts
  • web/packages/agenta-entities/src/drive/htmlApp/protocol.ts
  • web/packages/agenta-entities/src/drive/htmlApp/scope.ts
  • web/packages/agenta-entities/src/drive/htmlApp/scopeToken.ts
  • web/packages/agenta-entities/src/drive/htmlApp/stub.ts
  • web/packages/agenta-entities/src/drive/index.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.egress.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.etags.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.fsClient.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.grants.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.host.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.manifest.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.mockHost.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.scope.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.scopeToken.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.stub.test.ts
  • web/packages/agenta-entity-ui/package.json
  • web/packages/agenta-entity-ui/scripts/sync-kit-css.mjs
  • web/packages/agenta-entity-ui/src/drive/DriveExplorer.tsx
  • web/packages/agenta-entity-ui/src/drive/htmlApp/GrantSheet.tsx
  • web/packages/agenta-entity-ui/src/drive/htmlApp/HtmlAppBody.tsx
  • web/packages/agenta-entity-ui/src/drive/htmlApp/RunView.tsx
  • web/packages/agenta-entity-ui/src/drive/htmlApp/assemble.ts
  • web/packages/agenta-entity-ui/src/drive/htmlApp/index.ts
  • web/packages/agenta-entity-ui/src/drive/htmlApp/kit/agenta-app.css
  • web/packages/agenta-entity-ui/src/drive/htmlApp/kit/index.ts
  • web/packages/agenta-entity-ui/src/drive/htmlApp/kit/kitCss.ts
  • web/packages/agenta-entity-ui/src/drive/htmlApp/kit/tokens.ts
  • web/packages/agenta-entity-ui/src/drive/htmlApp/useAppManifest.ts
  • web/packages/agenta-entity-ui/src/drive/htmlApp/useChangedHint.ts
  • web/packages/agenta-entity-ui/src/drive/index.ts
  • web/packages/agenta-entity-ui/src/drive/renderers.tsx
  • web/packages/agenta-entity-ui/tests/unit/htmlApp.assemble.test.ts
  • web/packages/agenta-entity-ui/tests/unit/htmlApp.grantEscalation.test.tsx
  • web/packages/agenta-entity-ui/tests/unit/htmlApp.kit.test.ts
  • web/packages/agenta-shared/src/state/featureFlags.ts
  • web/packages/agenta-shared/src/state/index.ts
  • web/storybook/fixtures/boardStarter.ts
  • web/storybook/fixtures/htmlApp.ts
  • web/storybook/raw.d.ts
  • web/storybook/stories/entity-ui/htmlApp/BoardStarter.stories.tsx
  • web/storybook/stories/entity-ui/htmlApp/GrantSheet.stories.tsx
  • web/storybook/stories/entity-ui/htmlApp/HtmlApp.mdx
  • web/storybook/stories/entity-ui/htmlApp/HtmlAppBody.stories.tsx
  • web/storybook/stories/entity-ui/htmlApp/Kit.stories.tsx
  • web/storybook/stories/entity-ui/htmlApp/RunView.stories.tsx
💤 Files with no reviewable changes (1)
  • api/oss/tests/pytest/unit/apps/init.py
🚧 Files skipped from review as they are similar to previous changes (8)
  • api/oss/src/core/apps/skill/sections/01-when.md
  • api/oss/src/core/apps/starters/board@1/SKILL.md
  • api/oss/src/core/apps/skill/sections/02-first.md
  • api/oss/tests/pytest/unit/skills/test_registry_listing.py
  • api/oss/src/core/apps/skill/sections/06-starters.md
  • api/oss/src/core/apps/init.py
  • web/storybook/stories/entity-ui/htmlApp/HtmlApp.mdx
  • sdks/python/oss/tests/pytest/unit/agents/golden/run_request.gateway_connection.json

Comment on lines +86 to +117
class StarterInfo:
name: str
version: int
when: str
access: str = "read"
config_keys: Tuple[str, ...] = ()
data_files: Tuple[str, ...] = ()
# ``bundle`` starters copy; ``agent`` starters are listed only (copying is phase 3).
source: str = "bundle"

@property
def ref(self) -> str:
prefix = AGENT_STARTER_PREFIX if self.source == "agent" else ""
return f"{prefix}{self.name}@{self.version}"

def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"version": self.version,
"when": self.when,
"config_keys": list(self.config_keys),
"data_files": list(self.data_files),
"access": self.access,
"source": self.source,
}


@dataclass(frozen=True)
class CreateAppResult:
paths: List[str]
template: str
skipped: List[str] = field(default_factory=list)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- target outline ---'
ast-grep outline api/oss/src/core/apps/service.py
printf '%s\n' '--- target source ---'
sed -n '1,180p' api/oss/src/core/apps/service.py
printf '%s\n' '--- symbol references ---'
rg -n --glob '*.py' '\b(StarterInfo|CreateAppResult)\b|list_starters|create_app' api/oss/src

Repository: Agenta-AI/agenta

Length of output: 13619


🏁 Script executed:

#!/bin/bash
set -eu
sed -n '1,180p' api/oss/src/core/apps/service.py
printf '%s\n' '--- references ---'
rg -n --glob '*.py' '\b(StarterInfo|CreateAppResult)\b|list_starters|create_app' api/oss/src

Repository: Agenta-AI/agenta

Length of output: 12282


🤖 get_repo_knowledge executed:

get_repo_knowledge Agenta-AI/agenta /tmp/coderabbit-repo-knowledge/agenta-ai-agenta-4b53879a/conventions

Length of output: 23662


Return Pydantic DTOs from AppsService.

AppsService.list_starters returns List[StarterInfo], and AppsService.create_app returns CreateAppResult. Both result types are currently dataclasses. Convert them to BaseModel subclasses to comply with the repository service contract. Preserve the existing ref and to_dict() behavior.

Comment on lines +393 to +395
current = await self.mounts_service.read_file(
project_id=project_id, mount_id=mount_id, path=manifest_path
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Use the new preconditions to serialize app creation.

Two concurrent create_app calls can both observe a missing app.json. Both calls then write unconditionally. Calls with different starters can produce a manifest and entry file from different templates.

Create app.json with if_none_match_any=True. For updates, use the ETag returned by read_file with if_match. Treat a failed condition as an app conflict before writing the remaining template files.

Based on learnings, read-then-write flows require optimistic locking or another atomic control.

Also applies to: 429-434

Source: Learnings

Comment on lines +26 to +27
names while it is open; you own config and the rest. You may write data too, but read it
first: your writes are unconditional.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Prevent agent writes from overwriting live app data.

A read before an unconditional agent write does not prevent the app from saving newer data before that write. The later agent write can then replace the app update.

  • api/oss/src/core/apps/skill/SKILL.md#L26-L27: require a conditional write with conflict handling, or prohibit writes to declared data files while the app is open.
  • api/oss/src/core/apps/skill/sections/03-folder.md#L6-L7: keep the extracted guidance consistent with the same restriction.
📍 Affects 2 files
  • api/oss/src/core/apps/skill/SKILL.md#L26-L27 (this comment)
  • api/oss/src/core/apps/skill/sections/03-folder.md#L6-L7

Comment on lines +649 to +654
current = await self._current_etag(bucket=bucket, key=key)
if current is None or current != _normalize_etag(if_match):
raise StorePreconditionFailed(current)
client = self._client()
await client.put_object(bucket, key, BytesIO(body), length=len(body))
return len(body)
await client.remove_object(bucket, key)
return 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make conditional deletion atomic.

delete_object_if_match checks the ETag and deletes the key in separate requests. If another writer updates the object after _current_etag returns, remove_object deletes the new version even though its ETag does not match If-Match.

Use a native conditional delete where supported. Otherwise, delete the exact version returned by the stat operation, or reject conditional deletion on backends that cannot provide atomic semantics.

Comment on lines +990 to +1002
# Agent HTML apps. Both write to / read from the session's own drive, which RUN_TOOLS
# already covers (the mount endpoints ask for nothing more), so neither is elevated.
CREATE_APP_CALL_REF: PlatformToolHandlerRegistration(
call_ref=CREATE_APP_CALL_REF,
timeout_ms=CREATE_APP_DEFAULT_TIMEOUT_MS,
handler=handle_create_app,
needs_mounts=True,
),
LIST_STARTERS_CALL_REF: PlatformToolHandlerRegistration(
call_ref=LIST_STARTERS_CALL_REF,
timeout_ms=LIST_STARTERS_DEFAULT_TIMEOUT_MS,
handler=handle_list_starters,
needs_mounts=True,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | 🟠 Major | ⚡ Quick win

Authorization Bypass

Reachability: External
Exploitability: Moderate
CWE: CWE-862 — Missing Authorization

Enforce mount permissions for the app handlers.

An authenticated caller with RUN_TOOLS can reach both handlers. create_app then creates a session mount and writes files without EDIT_MOUNTS. list_starters reads the agent mount without VIEW_MOUNTS.

Set elevated_permission=Permission.EDIT_MOUNTS for create_app. Set elevated_permission=Permission.VIEW_MOUNTS for list_starters.

The session and artifact bindings limit which mount the caller targets. They do not replace the missing permission checks.

Comment on lines 333 to 346
"v1": _build_kit_revision,
},
},
AGENTA_APPS_SLUG: {
"kind": "skill",
"embeddable": True,
"latest": "v1",
"versions": {
"v1": _skill_revision(AGENTA_APPS_SKILL),
},
},
}


Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🔎 Supported by static analysis

🏁 Script executed:

rg -n "AGENTA_APPS|agenta-apps|Add.skills|skill.*picker|static.*catalog|DEFAULT_BUILD_KIT_OPS" api web sdks | head -250

Repository: Agenta-AI/agenta

Length of output: 32222


🏁 Script executed:

set -e
printf '%s\n' '--- skills service symbols and relevant body ---'
ast-grep outline api/oss/src/core/skills/service.py
sed -n '500,640p' api/oss/src/core/skills/service.py
printf '%s\n' '--- skill routes and service callers ---'
rg -n -C 4 'SkillsService|list_skills|list.*skill|attach.*skill|skill.*attach|registry.*skill|skills.*registry' api/oss/src api/entrypoints web/packages/agenta-skills-ui web/packages/agenta-ui | head -320
printf '%s\n' '--- build-kit overlay ---'
cat -n api/oss/src/core/workflows/build_kit.py | sed -n '1,190p'
cat -n api/oss/src/apis/fastapi/applications/overlay.py | sed -n '1,220p'
printf '%s\n' '--- static catalog and skill tests ---'
cat -n api/oss/src/core/workflows/static_catalog.py | sed -n '1,80p;300,355p'
cat -n api/oss/tests/pytest/unit/apps/test_skill_assembly.py | sed -n '55,95p'
cat -n api/oss/tests/pytest/unit/applications/test_build_kit_overlay.py | sed -n '220,255p;330,425p'

Repository: Agenta-AI/agenta

Length of output: 42590


🏁 Script executed:

set -e
printf '%s\n' '--- backend registry listing and route ---'
cat -n api/oss/src/core/skills/service.py | sed -n '145,275p'
cat -n api/oss/src/apis/fastapi/skills/router.py | sed -n '145,225p'
printf '%s\n' '--- picker host and drawer data/attachment callbacks ---'
cat -n web/packages/agenta-skills-ui/src/SkillPickerHost.tsx | sed -n '1,280p'
cat -n web/packages/agenta-skills-ui/src/SkillPickerDrawer.tsx | sed -n '1,260p'
printf '%s\n' '--- agent panel bridge and skill serialization ---'
cat -n web/packages/agenta-ui/src/drill-in/context/DrillInUIContext.tsx | sed -n '210,255p;370,410p'
rg -n -C 5 'SkillPickerHost|onAdd|onRemove|builtin|is_static|`@ag.embed`|create_app|list_starters' web/packages/agenta-skills-ui web/packages/agenta-ui web/oss/src | head -420

Repository: Agenta-AI/agenta

Length of output: 42071


🏁 Script executed:

set -e
printf '%s\n' '--- skills registry state and API response mapping ---'
rg -n -C 8 'skillsListDataAtom|SkillsResponse|builtin|query.*skills|/skills/query|list.*skills' web/packages/agenta-skills web/packages/agenta-skills-ui web/oss/src api/oss/src/apis/fastapi/skills api/oss/src/core/skills | head -500
printf '%s\n' '--- embed builder contract ---'
rg -n -C 10 'buildSkillEmbedEntry|SkillEmbedTarget' web/packages/agenta-skills web/packages/agenta-skills-ui web/packages/agenta-entities | head -260
printf '%s\n' '--- ordinary agent config and build-kit consumers ---'
rg -n -C 6 'build_agent_template_overlay|agent_template_overlay|DEFAULT_BUILD_KIT_OPS|create_app|list_starters|platform.*op|agent template overlay|SkillsPickerHost' api web/packages | head -600

Repository: Agenta-AI/agenta

Length of output: 42245


🏁 Script executed:

set -e
printf '%s\n' '--- production app operation registrations and consumers ---'
rg -n -C 6 'create_app|list_starters' api/oss/src api/entrypoints sdks/python/agenta web/packages/agenta-playground web/packages/agenta-chat web/packages/agenta-entities | head -500
printf '%s\n' '--- ordinary request defaults versus build-kit overlay ---'
rg -n -C 8 'buildAgentRequest|build_agent_template_overlay|agent_template_overlay|build.?kit|DEFAULT_BUILD_KIT_OPS|PLATFORM_OPS' web/packages/agenta-playground/src api/oss/src sdks/python/agenta/sdk | head -500

Repository: Agenta-AI/agenta

Length of output: 42346


Wire the built-in app skill and app operations into ordinary agents. The registry returns static skills in builtin, but SkillPickerHost reads only skillsListDataAtom, which contains query.data.skills. It never consumes builtinSkillsAtom. Therefore, ordinary agents cannot select __ag__agenta_apps or receive its @ag.embed entry.

create_app, list_starters, and the agenta-apps embed currently exist in the playground-only build-kit overlay. Add the builtin skill and both platform operations to the ordinary-agent configuration path. Otherwise ordinary agents cannot receive the app-creation instructions or call the operations required to create an app.

Comment on lines +185 to +189
it("a write without a prior read is unconditional", async () => {
const host = rw()
expectOk(await host.handle(req({method: "write", path: "index.html", body: "v2"})))
expect(host.files.get("index.html")).toBe("v2")
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Model create-only first writes in the mock.

This test requires an unseen-path write to overwrite an existing file. The real host sends ifNoneMatch when it has no cached ETag, so the server must return conflict if the path already exists.

Update createMockHtmlAppHost to enforce create-only writes. Update the fake client in htmlApp.host.test.ts to record and enforce ifNoneMatch. This keeps stories and parity tests able to reproduce first-write conflicts.

Comment on lines +31 to +54
it("rejects every traversal and malformed shape with a scope failure", () => {
const table = [
"",
"/",
"/etc/passwd",
"/apps/board/a.txt",
".",
"..",
"../x",
"a/../..",
"a/../b",
"a/./b",
"a//b",
"%2e%2e/x",
"a/%2E%2E/b",
"%2e",
"%2f..%2fx",
"%zz",
"a\\b",
"..\\x",
"a\x00b",
"a\nb",
"a\x7fb",
]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- scope helper ---'
cat -n web/packages/agenta-entities/src/drive/htmlApp/scope.ts | sed -n '1,95p'
printf '%s\n' '--- scope test ---'
cat -n web/packages/agenta-entities/tests/unit/htmlApp.scope.test.ts | sed -n '1,75p'

Repository: Agenta-AI/agenta

Length of output: 7547


Path Traversal

Reachability: External
CWE: CWE-22 — Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

Add percent-encoded backslash cases to the scope rejection table. normalizeAppPath decodes the input before it rejects backslashes. The current table covers only raw backslashes, so add %5c, %5C, and a%5c..%5csecret to protect the path boundary against regression.

Comment on lines +469 to +476
it("lets external, protocol-relative and fragment links fall through", async () => {
const r = await connected()
for (const href of ["https://example.com", "mailto:x@y.z", "//cdn.example.com/x", "#top"]) {
const a = r.doc.createElement("a")
a.setAttribute("href", href)
r.doc.body.appendChild(a)
expect(click(r, a), href).toBe(false)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | 🟠 Major | 🏗️ Heavy lift

Sensitive Data Exposure

Reachability: External
Exploitability: Trivial
CWE: CWE-200 — Exposure of Sensitive Information to an Unauthorized Actor

Block same-frame external navigation.

This test requires external links to bypass the bridge. A Run app can put mounted file data in the URL and navigate its own iframe. The browser then sends the URL to the external server.

The popup restriction does not block navigation of the iframe itself. CSP fetch directives do not govern navigation. A click-only interceptor is also insufficient because app code can assign window.location.

Add a browser-enforced control that blocks all external navigation from the Run context. Then route permitted destinations through the host.

Comment on lines +167 to +169
const [grant, setGrant] = useState<GrantLevel | null>(
() => (mountId ? grants.get(mountId, dir)?.level : null) ?? null,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | 🟠 Major | 🏗️ Heavy lift

Authorization Bypass

Reachability: Internal
Exploitability: Moderate
CWE: CWE-863 — Incorrect Authorization

Reset grant when the mount or app dir changes.

grant is seeded once from the grant store and is never cleared afterwards. dir is derived from path, and HtmlBody in renderers.tsx renders HtmlAppBody without a key, so selecting another HTML file only changes the props. DriveExplorer also keeps htmlView === "run" across selections.

Trigger: the user grants read-write for folder A, keeps Run selected, then opens an HTML file in folder B that has no stored grant.

Result: pickView("run") opens the sheet, but it does not clear view or grant. The host effect at lines 223-244 still runs with the new dir and the old grant, so RunView mounts at line 323 and getScopeToken mints a folder-B token at read-write. The app in folder B writes files before the user answers for that folder.

Clear the grant when the app identity changes.

🔧 Proposed fix
     const [grant, setGrant] = useState<GrantLevel | null>(
         () => (mountId ? grants.get(mountId, dir)?.level : null) ?? null,
     )
+    // A different mount or app dir is a different app: never carry the previous answer over.
+    useEffect(() => {
+        setGrant((mountId ? grants.get(mountId, dir)?.level : null) ?? null)
+        setSheetOpen(false)
+    }, [mountId, dir, grants])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [grant, setGrant] = useState<GrantLevel | null>(
() => (mountId ? grants.get(mountId, dir)?.level : null) ?? null,
)
const [grant, setGrant] = useState<GrantLevel | null>(
() => (mountId ? grants.get(mountId, dir)?.level : null) ?? null,
)
// A different mount or app dir is a different app: never carry the previous answer over.
useEffect(() => {
setGrant((mountId ? grants.get(mountId, dir)?.level : null) ?? null)
setSheetOpen(false)
}, [mountId, dir, grants])

The key was added from the review without a test, and a browser check of the
plain case proved nothing: swapping the entry re-renders either way, because the
content prop changes with it.

The defect needs the view to be somewhere else first. An app that navigated to a
sub-page holds `currentPath` there, and swapping the entry underneath it left
that pointing at the previous app's page — so the reader picked a different app
and kept reading the old one's sub-page under the new app's name.

This drives the host's `nav` callback to move the view off the entry, then swaps
the entry, and asserts the old app's sub-page is not fetched again. It fails with
the key removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ardaerzin

Copy link
Copy Markdown
Contributor Author

Correction and a fuller acceptance run. My previous comment said the RunView key was verified only by unit test and a React remount argument. That was not good enough, and one part of it was wrong.

The RunView key: my first check was testing the wrong thing

I originally claimed the key fixed a visible regression. When I actually built the control — same build, key removed — swapping the entry file behaved identically with and without it. A plain swap re-renders either way, because content changes with the path. So the "before" behaviour I described in the commit message was not what the code did.

The defect needs the view to be somewhere else first. An app that has navigated to a sub-page holds currentPath there, and only then does a stale entry show.

Verified both ways now.

In the browser, with an app that navigates to a sibling on load:

running apps/linked/index.html, view on the sub-page
  strip: Running · read · apps/linked · sub.html
swap the entry to other.html
  strip: Running · read · apps/linked      ← sub-page dropped
  iframe: THIRD PAGE ENTRY, no LINKED SUBPAGE

As a test (htmlApp.entrySwap.test.tsx): drives the host's nav callback off the entry, swaps the entry, and asserts the old app's sub-page is not fetched again. With the key removed it fails:

AssertionError: expected [ 'apps/board/sub.html', … ] to not include 'apps/board/sub.html'

Acceptance, re-run on this branch

# Step Result
1 Open an HTML file from the drive Pass — Source | Preview | Run
2 Run in a read-write folder Pass — sheet names app and dir, both levels offered. Viewer-role variant still needs a second member
3 Grant read-write Pass — Running · read + write · apps/launch-board
5 Reload, reopen Pass — resumes, no prompt
9 <script src> / CDN tag Pass — assembled CSP is default-src 'none'; script-src 'unsafe-inline', so no external host can load
10 Sibling link and a link outside the folder Pass — sibling navigates inside the app (strip gains · sub.html); the outside link leaves Run and opens that file in the drive
13 New browser session Pass — cleared grants re-prompt
15 Flag off Pass — Source | Preview only

Not reachable from this harness

Steps 4, 6, 7, 11 and 12 all need a click or a theme change observed inside the app. The Run iframe is sandbox="allow-scripts allow-forms" with no allow-same-origin, so neither page script nor this tool's DOM snapshot can reach into it. Playwright itself can drive a frame; the wrapper I am using does not expose frame locators. These need a human pass or a harness with frame support — I am not going to claim them from the outside.

What is covered for those paths instead: the conditional-write round trip against the live API (200412 {"code":"conflict","etag":…}200), and the mock-host unit tests.

Step 14 is blocked on unbuilt work, and it is worse than "no hook"

An agent asked for a board replies that it has no create_app tool. Attaching the skill by hand does not fix it either:

  • Skills are inlined on the agent revision (parameters.agent.skills[]), not referenced, so there is nothing to point at a static catalogue entry.
  • The Add-skills picker lists project registry skills; a first-party static entry never appears.
  • Platform tools are explicit entries in parameters.agent.tools[]. So attachment has to add both the skill body and the create_app / list_starters tool entries. Neither happens today.

16 — Storybook builds and the stories are present; the a11y runner and VRT were not run.

@mmabrouk
mmabrouk changed the base branch from release/v0.119.1 to release/v0.120.0 September 22, 2026 12:49

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
web/packages/agenta-entities/src/drive/htmlApp/scopeToken.ts (1)

57-64: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Cache failed scope-token mints for a short period.

getScopeToken caches successful tokens only. The in-flight entry is removed after the mint completes, including after failure. Therefore, each later filesystem operation can issue another failing mint for the same mount, directory, and level. This adds a failing network request before every file operation during an endpoint outage or deployment mismatch.

Add a short-lived negative cache and clear it in clearScopeTokens.

♻️ Suggested refactor
 const cache = new Map<string, CachedToken>()
 const inflight = new Map<string, Promise<string | null>>()
+const failures = new Map<string, number>()
+const MINT_RETRY_MS = 60_000
 export function clearScopeTokens(): void {
     cache.clear()
     inflight.clear()
+    failures.clear()
 }
     } catch {
         // A deployment that has not shipped the endpoint, or a transient failure. The app runs
         // unscoped, exactly as it did before the token existed; it must not be blocked on this.
+        failures.set(cacheKey(mountId, dir, level), Date.now())
         return null
     }
     const cached = cache.get(key)
     if (cached && cached.expiresAt - REFRESH_MARGIN_MS > Date.now()) return cached.token
+
+    const failedAt = failures.get(key)
+    if (failedAt !== undefined && Date.now() - failedAt < MINT_RETRY_MS) return null
 
     // Collapse a burst of parallel fs calls onto one mint.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Advanced

Run ID: 1130ff1c-c821-4129-a812-bd9f933cf953

📥 Commits

Reviewing files that changed from the base of the PR and between 79936b3 and 79936b3.

⛔ Files ignored due to path filters (9)
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/client/Client.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/client/requests/AppScopeRequest.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/client/requests/DeleteMountFileRequest.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/client/requests/GetMountFilesRequest.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/client/requests/WriteMountFileRequest.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/client/requests/index.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/types/AppScopeResponse.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/types/MountFileWrittenResponse.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/types/index.ts is excluded by !**/generated/**
📒 Files selected for processing (107)
  • api/entrypoints/routers.py
  • api/oss/src/apis/fastapi/mounts/models.py
  • api/oss/src/apis/fastapi/mounts/router.py
  • api/oss/src/apis/fastapi/tools/router.py
  • api/oss/src/core/apps/__init__.py
  • api/oss/src/core/apps/assembly.py
  • api/oss/src/core/apps/handlers.py
  • api/oss/src/core/apps/scope_token.py
  • api/oss/src/core/apps/service.py
  • api/oss/src/core/apps/skill/SKILL.md
  • api/oss/src/core/apps/skill/sections/01-when.md
  • api/oss/src/core/apps/skill/sections/02-first.md
  • api/oss/src/core/apps/skill/sections/03-folder.md
  • api/oss/src/core/apps/skill/sections/04-bridge.md
  • api/oss/src/core/apps/skill/sections/05-custom-rules.md
  • api/oss/src/core/apps/skill/sections/06-starters.md
  • api/oss/src/core/apps/skill/sections/07-after.md
  • api/oss/src/core/apps/skill/sections/08-agent-level.md
  • api/oss/src/core/apps/starters/board@1/SKILL.md
  • api/oss/src/core/apps/starters/board@1/app.json
  • api/oss/src/core/apps/starters/board@1/config.defaults.json
  • api/oss/src/core/apps/starters/board@1/index.html
  • api/oss/src/core/mounts/dtos.py
  • api/oss/src/core/mounts/service.py
  • api/oss/src/core/mounts/types.py
  • api/oss/src/core/store/dtos.py
  • api/oss/src/core/store/storage.py
  • api/oss/src/core/store/types.py
  • api/oss/src/core/tools/platform_handlers.py
  • api/oss/src/core/workflows/build_kit.py
  • api/oss/src/core/workflows/static_catalog.py
  • api/oss/tests/pytest/unit/applications/test_build_kit_overlay.py
  • api/oss/tests/pytest/unit/apps/__init__.py
  • api/oss/tests/pytest/unit/apps/conftest.py
  • api/oss/tests/pytest/unit/apps/test_create_app.py
  • api/oss/tests/pytest/unit/apps/test_list_starters.py
  • api/oss/tests/pytest/unit/apps/test_op_catalog_parity.py
  • api/oss/tests/pytest/unit/apps/test_scope_token.py
  • api/oss/tests/pytest/unit/apps/test_skill_assembly.py
  • api/oss/tests/pytest/unit/apps/test_starter_manifest.py
  • api/oss/tests/pytest/unit/mounts/test_mount_file_conditional_routes.py
  • api/oss/tests/pytest/unit/mounts/test_protected_mount_policy.py
  • api/oss/tests/pytest/unit/mounts/test_store_conditional_ops.py
  • api/oss/tests/pytest/unit/skills/test_registry_listing.py
  • api/oss/tests/pytest/unit/test_mounts_file_ops.py
  • api/oss/tests/pytest/unit/workflows/test_static_catalog.py
  • docs/design/agent-html-apps/contracts.md
  • sdks/python/agenta/sdk/agents/platform/op_catalog.py
  • sdks/python/agenta/sdk/agents/platform_instructions.py
  • sdks/python/oss/tests/pytest/unit/agents/golden/run_request.gateway_connection.json
  • sdks/python/oss/tests/pytest/unit/agents/platform/test_op_catalog.py
  • sdks/python/oss/tests/pytest/unit/agents/test_platform_instructions.py
  • web/mobile/src/features/settings/PreferencesTab.tsx
  • web/oss/src/components/pages/settings/Preferences/Preferences.tsx
  • web/packages/agenta-entities/src/drive/htmlApp/etags.ts
  • web/packages/agenta-entities/src/drive/htmlApp/fsClient.ts
  • web/packages/agenta-entities/src/drive/htmlApp/grants.ts
  • web/packages/agenta-entities/src/drive/htmlApp/host.ts
  • web/packages/agenta-entities/src/drive/htmlApp/index.ts
  • web/packages/agenta-entities/src/drive/htmlApp/manifest.ts
  • web/packages/agenta-entities/src/drive/htmlApp/mockHost.ts
  • web/packages/agenta-entities/src/drive/htmlApp/protocol.ts
  • web/packages/agenta-entities/src/drive/htmlApp/scope.ts
  • web/packages/agenta-entities/src/drive/htmlApp/scopeToken.ts
  • web/packages/agenta-entities/src/drive/htmlApp/stub.ts
  • web/packages/agenta-entities/src/drive/index.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.egress.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.etags.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.fsClient.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.grants.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.host.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.manifest.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.mockHost.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.scope.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.scopeToken.test.ts
  • web/packages/agenta-entities/tests/unit/htmlApp.stub.test.ts
  • web/packages/agenta-entity-ui/package.json
  • web/packages/agenta-entity-ui/scripts/sync-kit-css.mjs
  • web/packages/agenta-entity-ui/src/drive/DriveExplorer.tsx
  • web/packages/agenta-entity-ui/src/drive/htmlApp/GrantSheet.tsx
  • web/packages/agenta-entity-ui/src/drive/htmlApp/HtmlAppBody.tsx
  • web/packages/agenta-entity-ui/src/drive/htmlApp/RunView.tsx
  • web/packages/agenta-entity-ui/src/drive/htmlApp/assemble.ts
  • web/packages/agenta-entity-ui/src/drive/htmlApp/index.ts
  • web/packages/agenta-entity-ui/src/drive/htmlApp/kit/agenta-app.css
  • web/packages/agenta-entity-ui/src/drive/htmlApp/kit/index.ts
  • web/packages/agenta-entity-ui/src/drive/htmlApp/kit/kitCss.ts
  • web/packages/agenta-entity-ui/src/drive/htmlApp/kit/tokens.ts
  • web/packages/agenta-entity-ui/src/drive/htmlApp/useAppManifest.ts
  • web/packages/agenta-entity-ui/src/drive/htmlApp/useChangedHint.ts
  • web/packages/agenta-entity-ui/src/drive/index.ts
  • web/packages/agenta-entity-ui/src/drive/renderers.tsx
  • web/packages/agenta-entity-ui/tests/unit/htmlApp.assemble.test.ts
  • web/packages/agenta-entity-ui/tests/unit/htmlApp.entrySwap.test.tsx
  • web/packages/agenta-entity-ui/tests/unit/htmlApp.grantEscalation.test.tsx
  • web/packages/agenta-entity-ui/tests/unit/htmlApp.kit.test.ts
  • web/packages/agenta-shared/src/state/featureFlags.ts
  • web/packages/agenta-shared/src/state/index.ts
  • web/storybook/fixtures/boardStarter.ts
  • web/storybook/fixtures/htmlApp.ts
  • web/storybook/raw.d.ts
  • web/storybook/stories/entity-ui/htmlApp/BoardStarter.stories.tsx
  • web/storybook/stories/entity-ui/htmlApp/GrantSheet.stories.tsx
  • web/storybook/stories/entity-ui/htmlApp/HtmlApp.mdx
  • web/storybook/stories/entity-ui/htmlApp/HtmlAppBody.stories.tsx
  • web/storybook/stories/entity-ui/htmlApp/Kit.stories.tsx
  • web/storybook/stories/entity-ui/htmlApp/RunView.stories.tsx
💤 Files with no reviewable changes (1)
  • api/oss/tests/pytest/unit/apps/init.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • web/storybook/stories/entity-ui/htmlApp/HtmlApp.mdx
  • api/oss/src/core/apps/init.py
  • api/oss/src/core/apps/skill/sections/02-first.md

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.

mount_id=mount.id,
starter=str(parsed.get("starter") or ""),
dir=str(parsed.get("dir") or ""),
update=bool(parsed.get("update") or False),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject non-boolean update values.

bool(parsed.get("update") or False) converts every non-empty string to True. For example, "update": "false" enables update mode. An invalid tool call can then overwrite template files in an existing app instead of returning an argument error.

Validate the handler arguments with a typed model, or require isinstance(update, bool) before calling AppsService.create_app().

Comment on lines +444 to +446
if file.name == APP_MANIFEST_FILENAME:
text = _stamp_template(text, template)
await put(file.name, text)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Write app.json only after the app files succeed.

The sorted loop writes app.json before index.html and the default configuration. If a later write fails, the folder contains a valid manifest for an incomplete app. A normal retry then returns app_exists.

Hold the stamped manifest separately. Write the entry and configuration files first. Publish app.json last as the app completion marker.

# other rules alone. Never rename it: an old rule under a stale id would linger forever.
_RETENTION_RULE_ID = "agenta-noncurrent-version-expiration"

_NOT_FOUND_CODES = ("NoSuchKey", "NoSuchObject", "NoSuchBucket")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not classify a missing bucket as a missing file.

_NOT_FOUND_CODES includes NoSuchBucket. Reads and stats therefore translate an absent bucket into MountFileNotFound. A conditional PUT also reports HTTP 412 because _current_etag() converts the absent bucket to None.

This hides a storage outage behind incorrect file and conflict responses. Handle NoSuchBucket as MountStorageUnavailable. Reserve the not-found tuple for object-level codes.

Comment on lines +75 to +76
the cached etag) and retry. `force: true` on the request skips the header and overwrites. A path
the app has never read is written unconditionally. `externalWrite` on the mock is how a test or

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the first-write rule; it states the pre-fix behaviour.

Line 76 says a path the app has never read is written unconditionally. The host now sends If-None-Match: * in that case: host.ts computes ifNoneMatch = req.force !== true && ifMatch === undefined, and htmlApp.fsClient.test.ts pins the header. A first write to an existing path therefore returns conflict, not success.

Update this paragraph so the bridge section agrees with the API deltas section, which already documents If-None-Match: *.

📝 Proposed wording
-`force: true` on the request skips the header and overwrites. A path
-the app has never read is written unconditionally. `externalWrite` on the mock is how a test or
+`force: true` on the request skips both headers and overwrites. A path
+the app has never read is written create-only (`If-None-Match: *`), so a path another session
+already created comes back as `conflict` instead of being overwritten. `externalWrite` on the mock is how a test or
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
the cached etag) and retry. `force: true` on the request skips the header and overwrites. A path
the app has never read is written unconditionally. `externalWrite` on the mock is how a test or
the cached etag) and retry. `force: true` on the request skips both headers and overwrites. A path
the app has never read is written create-only (`If-None-Match: *`), so a path another session
already created comes back as `conflict` instead of being overwritten. `externalWrite` on the mock is how a test or

Comment on lines +286 to +289
useEffect(() => {
if (!controlledView) return
pickView(controlledView === "run" && runnable ? "run" : "preview")
}, [controlledView, runnable, pickView])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Wait for the manifest before Run opens the grant sheet.

useAppManifest exposes loaded, but this component reads only manifest. When the Files pane toolbar selects Run, HtmlAppBody mounts with controlledView === "run" and this effect runs before the app.json fetch resolves. requestedAccess is then "read".

Result: an app whose manifest asks for read-write gets a sheet that preselects "Read files", the stored asked becomes read, and the user is prompted a second time once the manifest lands.

Gate the Run transition on loaded.

🔧 Proposed fix
-    const {manifest} = useAppManifest(runnable ? io : null, dir)
+    const {manifest, loaded: manifestLoaded} = useAppManifest(runnable ? io : null, dir)
     // Host-owned tabs: follow the host's view; Run still goes through the grant.
     useEffect(() => {
         if (!controlledView) return
-        pickView(controlledView === "run" && runnable ? "run" : "preview")
-    }, [controlledView, runnable, pickView])
+        // The manifest carries `access`: asking before it lands would offer the wrong level.
+        if (controlledView === "run" && runnable && !manifestLoaded) return
+        pickView(controlledView === "run" && runnable ? "run" : "preview")
+    }, [controlledView, runnable, manifestLoaded, pickView])

@flyovers

flyovers Bot commented Sep 22, 2026

Copy link
Copy Markdown

Play the flyover — 4 chapters, 2:41

Watch the flyover → · 2:41 · 4 chapters · 116 files

An animated walkthrough of this diff: the shape of the change, what it adds, how the new pieces connect, and where the risk sits.

This flyover is shared with the repo maintainer @mmabrouk's permission. Please don't mark it as spam. If you are not satisfied with the generated content, we'd greatly appreciate any feedback in the comments instead.

79936b3 · comment /flyover to refresh this for the latest commits · flyovers

@mmabrouk
mmabrouk merged commit 35a4acd into release/v0.120.0 Sep 22, 2026
77 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants