Skip to content

feat(userspace): web app-runtime foundation (#89 P3a)#970

Merged
jaylfc merged 3 commits into
devfrom
feat/userspace-runtime
Jun 17, 2026
Merged

feat(userspace): web app-runtime foundation (#89 P3a)#970
jaylfc merged 3 commits into
devfrom
feat/userspace-runtime

Conversation

@jaylfc

@jaylfc jaylfc commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Phase P3a of the app system (#89): re-integrate the web userspace app-runtime foundation onto current dev. This is the foundation that real .taosapp app packages run on.

What lands

The App Runtime work (originally PR #476, whose branch was ~598 commits behind dev) re-integrated onto current dev, web-only:

  • tinyagentos/userspace/: package.py (manifest + zip extraction), store.py (UserspaceAppStore), data_store.py (UserspaceDataStore), broker.py (capability dispatcher, free + gated caps), url_guard.py (SSRF guard), sdk/taos-app-sdk.js.
  • tinyagentos/routes/userspace_apps.py (9 endpoints) wired via routes/init.py.
  • app.py: the two userspace stores constructed + init/close in the lifespan, mirroring InstalledAppsStore.
  • desktop: SandboxedAppWindow.tsx (the sandboxed iframe host) + lib/userspace-apps.ts.

Scope boundaries (deliberate)

  • Web-only: container support is excluded for now. Manifests still parse app_type "container", but the install path raises NotImplementedError("container packages: web-only for now") instead of importing container-deploy code.
  • No first-party trust enhancements yet (theme injection, relaxed CSP, broader caps, signature verification): those are P3b.
  • Additive and not yet surfaced in the UI: nothing installs or launches a userspace app from the Store yet. P4 migrates a reference studio onto this runtime and wires the Store/launcher path. This PR is the safe, tested foundation.

Tests

40 backend (tests/userspace/) + 9 frontend (SandboxedAppWindow + userspace-apps) pass; app create_app smoke OK; tsc clean; build green.

Part of #89.

Summary by CodeRabbit

New Features

  • Userspace Apps System: Install, list, enable/disable, and uninstall custom web apps.
  • Sandboxed App Execution: Render apps in a sandboxed iframe with a browser SDK and capability broker for KV, tables, notifications, memory search, agent/LLM, and controlled networking.
  • Permissions & Consent Flow: Compute requested permission changes and require grants before gated capabilities.
  • Desktop Support: Add userspace manifest conversion/client helpers and support a new userspace app category.

Security

  • Stronger SSRF & Isolation: Block unsafe install URLs, enforce app-directory containment for bundles/files, prevent path traversal/zip-bomb issues, and gate capabilities (permission denied/unknown/invalid responses).

@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Introduces a complete sandboxed .taosapp userspace runtime. The backend adds package parsing/validation with SSRF guard, SQLite-backed app and data stores, a capability broker (KV, table, files, network proxy, memory, agent, LLM), and FastAPI routes for install/manage/broker/bundle-serve. The frontend adds a React SandboxedAppWindow iframe component, a browser-side window.taos SDK, and TypeScript API helpers.

Changes

Userspace Apps Runtime

Layer / File(s) Summary
Package parsing, manifest validation, and SSRF guard
tinyagentos/userspace/package.py, tinyagentos/userspace/url_guard.py, tests/userspace/test_package.py, tests/userspace/test_url_guard.py
PackageError, parse_manifest (YAML, required-field checks, app_type allow-list, container schema validation), extract_package (zip extraction with zip-bomb defense and path-traversal enforcement), and is_safe_public_url (DNS resolution + IP classification for SSRF blocking). Tests cover valid manifests, rejected app types, missing fields, file extraction, path traversal, unsafe members, zip-bomb defense, and all URL safety cases.
UserspaceAppStore and UserspaceDataStore persistence
tinyagentos/userspace/store.py, tinyagentos/userspace/data_store.py, tests/userspace/test_store.py, tests/userspace/test_data_store.py
UserspaceAppStore (SQLite upsert install, permissions/enabled/runtime-location updates, uninstall) and UserspaceDataStore (per-app KV and row-table stores with CRUD). Tests verify per-app namespacing, runtime location preservation on upsert, and scoped table operations.
Capability broker and browser SDK
tinyagentos/userspace/broker.py, tinyagentos/userspace/sdk/taos-app-sdk.js, tests/userspace/test_broker.py
handle_capability with FREE_CAPS/GATED_CAPS policy dispatching KV, table, file-jailed, notify, memory, agent, LLM, and app.net proxy calls (blocked proxy headers, path traversal rejection, 30s timeout). taos-app-sdk.js IIFE exposes window.taos API via sequenced postMessage. Tests cover gating, scoping, jailing, header filtering, and path traversal rejection.
FastAPI routes, app lifecycle wiring, and integration tests
tinyagentos/userspace/__init__.py, tinyagentos/app.py, tinyagentos/routes/__init__.py, tinyagentos/routes/userspace_apps.py, tests/userspace/conftest.py, tests/userspace/test_routes.py, tests/userspace/test_broker_route.py, tests/userspace/test_sdk_route.py, tests/userspace/test_e2e.py, tests/userspace/test_immutability.py, tests/userspace/test_install_security.py, tests/userspace/test_update_consent.py
Wires stores into FastAPI lifespan and registers the userspace_apps router. Routes cover SDK serving, app listing, SSRF-guarded install (file or source_url), permissions grant, enable/disable, safe uninstall, bundle/icon serving with CSP and nosniff, and the broker endpoint. Tests cover the full lifecycle, broker enforcement, SSRF blocking, CSP headers, path traversal 404s, native/container rejection, and consent detection.
Desktop client: AppManifest type, API helpers, and SandboxedAppWindow
desktop/src/registry/app-registry.ts, desktop/src/lib/userspace-apps.ts, desktop/src/apps/SandboxedAppWindow.tsx, desktop/src/apps/__tests__/SandboxedAppWindow.test.tsx, desktop/src/lib/__tests__/userspace-apps.test.ts
Extends AppManifest.category with "userspace", adds toAppManifest, installUserspaceApp, grantUserspacePermissions, and fetchUserspaceApps. SandboxedAppWindow renders a sandboxed iframe, validates postMessage origin, forwards capability requests to the broker with credential inclusion, and posts replies back. Tests cover iframe attributes, message bridging, ignored non-iframe sources, and args coercion.

Sequence Diagram(s)

sequenceDiagram
  participant WebApp as Sandboxed iframe (web app)
  participant SDK as taos-app-sdk.js
  participant SandboxedAppWindow as SandboxedAppWindow (React)
  participant BrokerRoute as POST /api/userspace-apps/{app_id}/broker
  participant handle_capability as handle_capability

  WebApp->>SDK: window.taos.kv.get("mykey")
  SDK->>SandboxedAppWindow: postMessage { taosApp, id, capability: "app.kv.get", args }
  SandboxedAppWindow->>BrokerRoute: fetch POST with { capability, args }, credentials: include
  BrokerRoute->>handle_capability: capability, args, granted, data_store, app_dir, services
  handle_capability-->>BrokerRoute: { result: value }
  BrokerRoute-->>SandboxedAppWindow: 200 JSON { result: value }
  SandboxedAppWindow->>SDK: iframe.contentWindow.postMessage { taosAppReply: id, result: value }
  SDK-->>WebApp: resolved Promise(value)
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐇 A tiny taos app hops into its frame,
Sandboxed in iframes, no two look the same.
The broker dispatches each capability call,
Path traversal blocked — safe jails for all!
KV keys stored, table rows aligned,
A userspace runtime, carefully designed. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.75% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(userspace): web app-runtime foundation (#89 P3a)' clearly and concisely describes the main change: integrating the web userspace app-runtime foundation with specific phase/issue references.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/userspace-runtime

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 and usage tips.

Comment thread tinyagentos/routes/userspace_apps.py Outdated
Comment on lines +74 to +88
try:
manifest = extract_package(data, apps_root=_apps_root(request))
except PackageError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
existing = await store.get(manifest["id"])
new_perms = [
p for p in manifest["permissions"]
if existing and p not in existing["permissions_granted"]
]
await store.install(
app_id=manifest["id"], name=manifest["name"], version=manifest["version"],
app_type=manifest["app_type"], entry=manifest["entry"], icon=manifest["icon"],
permissions_requested=manifest["permissions"],
)
deploy_info: dict = {}

@gitar-bot gitar-bot Bot Jun 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Bug: Container install persists app + files then returns 500

In install_app, the order of operations is: extract_package(...) writes the bundle to apps_root/{id}/, then store.install(...) persists the app row, and only afterwards does the code check if manifest['app_type'] == 'container' and raise NotImplementedError("container packages: web-only for now"). Because the exception is raised after both the disk extraction and the DB insert, a container .taosapp install:

  1. Returns an unhandled 500 to the caller (NotImplementedError is not converted to a 4xx), and
  2. Leaves the app registered in userspace_apps and its files on disk.

The inline comment ("Re-installs of container apps are accepted and stored, but not deployed") contradicts the actual behavior — the caller sees a server error, not a success. Worse, the now-persisted container app appears in list_installed(); fetchUserspaceApps()/toAppManifest map every row (regardless of app_type) to the web SandboxedAppWindow, so a container package would be served/launched as a web iframe. The intended "web-only, reject container" boundary should fail fast and cleanly before any state is mutated, and ideally surface a 501/400 rather than a raw 500.

Reject container packages before store.install() with a proper status code. (Consider also cleaning up the extracted dir, or validating app_type before extract_package.):

try:
    manifest = extract_package(data, apps_root=_apps_root(request))
except PackageError as exc:
    return JSONResponse({"error": str(exc)}, status_code=400)
if manifest["app_type"] == "container":
    # Web-only for now: reject before mutating any state.
    return JSONResponse(
        {"error": "container packages: web-only for now"},
        status_code=501,
    )
existing = await store.get(manifest["id"])
...

Was this helpful? React with 👍 / 👎

Comment on lines +52 to +66
@router.post("/api/userspace-apps/install")
async def install_app(request: Request, package: UploadFile | None = File(default=None)):
store = request.app.state.userspace_apps
if package is not None:
data = await package.read()
else:
body = await request.json()
url = body.get("source_url")
if not url:
return JSONResponse({"error": "source_url or package required"}, status_code=400)
# SSRF guard: only fetch public http(s) hosts, and do not follow
# redirects (a 3xx could bounce to a blocked internal address).
if not is_safe_public_url(url):
return JSONResponse(
{"error": "source_url is not allowed -- only public http(s) hosts "

@gitar-bot gitar-bot Bot Jun 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Security: Install endpoint has no upload/zip size limits (zip-bomb DoS)

install_app reads the entire upload into memory with data = await package.read() (or data = resp.content for source_url), then extract_package builds a zipfile.ZipFile(io.BytesIO(data)) and writes every member via dest.write_bytes(zf.read(member)) with no checks on the compressed size, uncompressed size, member count, or total extracted bytes. An authenticated user can submit a small zip-bomb (or a multi-GB upload) and exhaust memory/disk on the host. Even though the endpoint is auth-gated, a single compromised/over-privileged session can take the service down. Add limits: cap the raw upload size, cap per-member and total uncompressed size (using ZipInfo.file_size), and cap member count before extracting.

Bound member count and total uncompressed size before extraction; also enforce a max raw upload size on the route.:

_MAX_TOTAL_UNCOMPRESSED = 100 * 1024 * 1024  # 100 MiB
_MAX_MEMBERS = 5000

# in extract_package, before the write loop:
    infos = [i for i in zf.infolist() if not i.filename.endswith("/")]
    if len(infos) > _MAX_MEMBERS:
        raise PackageError("package has too many files")
    total = sum(i.file_size for i in infos)
    if total > _MAX_TOTAL_UNCOMPRESSED:
        raise PackageError("package expands to too large a size")

Was this helpful? React with 👍 / 👎

Comment on lines +54 to +65
if capability in ("app.files.read", "app.files.write"):
files_root = (Path(app_dir) / "files").resolve()
target = (files_root / args.get("path", "")).resolve()
if not str(target).startswith(str(files_root) + "/") and target != files_root:
return {"error": "invalid_path"}
if capability == "app.files.read":
if not target.is_file():
return {"error": "not_found"}
return {"result": target.read_text()}
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(args.get("content", ""))
return {"result": True}

@gitar-bot gitar-bot Bot Jun 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Edge Case: app.files.write to the jail root raises uncaught IsADirectoryError

In handle_capability the path guard accepts the case target == files_root (the jail root directory itself). For app.files.read that is harmless (is_file() is False -> not_found). But for app.files.write with an empty/'.' path, target resolves to files_root, and the code does target.parent.mkdir(...) then target.write_text(...) on a directory, raising IsADirectoryError. Since the broker route does not wrap handle_capability in a try/except, this surfaces as an unhandled 500 instead of a clean {"error": ...}. Reject writes whose target is the jail root (or any existing directory).

Reject writes that target a directory / the jail root.:

if capability == "app.files.read":
    if not target.is_file():
        return {"error": "not_found"}
    return {"result": target.read_text()}
if target.is_dir() or target == files_root:
    return {"error": "invalid_path"}
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(args.get("content", ""))
return {"result": True}

Was this helpful? React with 👍 / 👎

@gitar-bot

gitar-bot Bot commented Jun 16, 2026

Copy link
Copy Markdown

Note

Your trial team has used its Gitar budget, so automatic reviews are paused. Upgrade now to unlock full capacity. Comment "Gitar review" to trigger a review manually.
Learn more about usage limits

Code Review ⚠️ Changes requested 0 resolved / 3 findings

Re-integrates the userspace app-runtime foundation but requires fixes for an unhandled directory write exception, missing zip upload size limits, and a 500 error occurring during container package installation.

⚠️ Bug: Container install persists app + files then returns 500

📄 tinyagentos/routes/userspace_apps.py:74-88

In install_app, the order of operations is: extract_package(...) writes the bundle to apps_root/{id}/, then store.install(...) persists the app row, and only afterwards does the code check if manifest['app_type'] == 'container' and raise NotImplementedError("container packages: web-only for now"). Because the exception is raised after both the disk extraction and the DB insert, a container .taosapp install:

  1. Returns an unhandled 500 to the caller (NotImplementedError is not converted to a 4xx), and
  2. Leaves the app registered in userspace_apps and its files on disk.

The inline comment ("Re-installs of container apps are accepted and stored, but not deployed") contradicts the actual behavior — the caller sees a server error, not a success. Worse, the now-persisted container app appears in list_installed(); fetchUserspaceApps()/toAppManifest map every row (regardless of app_type) to the web SandboxedAppWindow, so a container package would be served/launched as a web iframe. The intended "web-only, reject container" boundary should fail fast and cleanly before any state is mutated, and ideally surface a 501/400 rather than a raw 500.

Reject container packages before store.install() with a proper status code. (Consider also cleaning up the extracted dir, or validating app_type before extract_package.)
try:
    manifest = extract_package(data, apps_root=_apps_root(request))
except PackageError as exc:
    return JSONResponse({"error": str(exc)}, status_code=400)
if manifest["app_type"] == "container":
    # Web-only for now: reject before mutating any state.
    return JSONResponse(
        {"error": "container packages: web-only for now"},
        status_code=501,
    )
existing = await store.get(manifest["id"])
...
⚠️ Security: Install endpoint has no upload/zip size limits (zip-bomb DoS)

📄 tinyagentos/routes/userspace_apps.py:52-66 📄 tinyagentos/userspace/package.py:49-63

install_app reads the entire upload into memory with data = await package.read() (or data = resp.content for source_url), then extract_package builds a zipfile.ZipFile(io.BytesIO(data)) and writes every member via dest.write_bytes(zf.read(member)) with no checks on the compressed size, uncompressed size, member count, or total extracted bytes. An authenticated user can submit a small zip-bomb (or a multi-GB upload) and exhaust memory/disk on the host. Even though the endpoint is auth-gated, a single compromised/over-privileged session can take the service down. Add limits: cap the raw upload size, cap per-member and total uncompressed size (using ZipInfo.file_size), and cap member count before extracting.

Bound member count and total uncompressed size before extraction; also enforce a max raw upload size on the route.
_MAX_TOTAL_UNCOMPRESSED = 100 * 1024 * 1024  # 100 MiB
_MAX_MEMBERS = 5000

# in extract_package, before the write loop:
    infos = [i for i in zf.infolist() if not i.filename.endswith("/")]
    if len(infos) > _MAX_MEMBERS:
        raise PackageError("package has too many files")
    total = sum(i.file_size for i in infos)
    if total > _MAX_TOTAL_UNCOMPRESSED:
        raise PackageError("package expands to too large a size")
💡 Edge Case: app.files.write to the jail root raises uncaught IsADirectoryError

📄 tinyagentos/userspace/broker.py:54-65

In handle_capability the path guard accepts the case target == files_root (the jail root directory itself). For app.files.read that is harmless (is_file() is False -> not_found). But for app.files.write with an empty/'.' path, target resolves to files_root, and the code does target.parent.mkdir(...) then target.write_text(...) on a directory, raising IsADirectoryError. Since the broker route does not wrap handle_capability in a try/except, this surfaces as an unhandled 500 instead of a clean {"error": ...}. Reject writes whose target is the jail root (or any existing directory).

Reject writes that target a directory / the jail root.
if capability == "app.files.read":
    if not target.is_file():
        return {"error": "not_found"}
    return {"result": target.read_text()}
if target.is_dir() or target == files_root:
    return {"error": "invalid_path"}
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(args.get("content", ""))
return {"result": True}
🤖 Prompt for agents
Code Review: Re-integrates the userspace app-runtime foundation but requires fixes for an unhandled directory write exception, missing zip upload size limits, and a 500 error occurring during container package installation.

1. ⚠️ Bug: Container install persists app + files then returns 500
   Files: tinyagentos/routes/userspace_apps.py:74-88

   In install_app, the order of operations is: extract_package(...) writes the bundle to apps_root/{id}/, then store.install(...) persists the app row, and only afterwards does the code check `if manifest['app_type'] == 'container'` and `raise NotImplementedError("container packages: web-only for now")`. Because the exception is raised after both the disk extraction and the DB insert, a container .taosapp install:
   
   1. Returns an unhandled 500 to the caller (NotImplementedError is not converted to a 4xx), and
   2. Leaves the app registered in userspace_apps and its files on disk.
   
   The inline comment ("Re-installs of container apps are accepted and stored, but not deployed") contradicts the actual behavior — the caller sees a server error, not a success. Worse, the now-persisted container app appears in list_installed(); fetchUserspaceApps()/toAppManifest map every row (regardless of app_type) to the web SandboxedAppWindow, so a container package would be served/launched as a web iframe. The intended "web-only, reject container" boundary should fail fast and cleanly before any state is mutated, and ideally surface a 501/400 rather than a raw 500.

   Fix (Reject container packages before store.install() with a proper status code. (Consider also cleaning up the extracted dir, or validating app_type before extract_package.)):
   try:
       manifest = extract_package(data, apps_root=_apps_root(request))
   except PackageError as exc:
       return JSONResponse({"error": str(exc)}, status_code=400)
   if manifest["app_type"] == "container":
       # Web-only for now: reject before mutating any state.
       return JSONResponse(
           {"error": "container packages: web-only for now"},
           status_code=501,
       )
   existing = await store.get(manifest["id"])
   ...

2. ⚠️ Security: Install endpoint has no upload/zip size limits (zip-bomb DoS)
   Files: tinyagentos/routes/userspace_apps.py:52-66, tinyagentos/userspace/package.py:49-63

   install_app reads the entire upload into memory with `data = await package.read()` (or `data = resp.content` for source_url), then extract_package builds a `zipfile.ZipFile(io.BytesIO(data))` and writes every member via `dest.write_bytes(zf.read(member))` with no checks on the compressed size, uncompressed size, member count, or total extracted bytes. An authenticated user can submit a small zip-bomb (or a multi-GB upload) and exhaust memory/disk on the host. Even though the endpoint is auth-gated, a single compromised/over-privileged session can take the service down. Add limits: cap the raw upload size, cap per-member and total uncompressed size (using ZipInfo.file_size), and cap member count before extracting.

   Fix (Bound member count and total uncompressed size before extraction; also enforce a max raw upload size on the route.):
   _MAX_TOTAL_UNCOMPRESSED = 100 * 1024 * 1024  # 100 MiB
   _MAX_MEMBERS = 5000
   
   # in extract_package, before the write loop:
       infos = [i for i in zf.infolist() if not i.filename.endswith("/")]
       if len(infos) > _MAX_MEMBERS:
           raise PackageError("package has too many files")
       total = sum(i.file_size for i in infos)
       if total > _MAX_TOTAL_UNCOMPRESSED:
           raise PackageError("package expands to too large a size")

3. 💡 Edge Case: app.files.write to the jail root raises uncaught IsADirectoryError
   Files: tinyagentos/userspace/broker.py:54-65

   In handle_capability the path guard accepts the case `target == files_root` (the jail root directory itself). For app.files.read that is harmless (is_file() is False -> not_found). But for app.files.write with an empty/'.' path, target resolves to files_root, and the code does `target.parent.mkdir(...)` then `target.write_text(...)` on a directory, raising IsADirectoryError. Since the broker route does not wrap handle_capability in a try/except, this surfaces as an unhandled 500 instead of a clean {"error": ...}. Reject writes whose target is the jail root (or any existing directory).

   Fix (Reject writes that target a directory / the jail root.):
   if capability == "app.files.read":
       if not target.is_file():
           return {"error": "not_found"}
       return {"result": target.read_text()}
   if target.is_dir() or target == files_root:
       return {"error": "invalid_path"}
   target.parent.mkdir(parents=True, exist_ok=True)
   target.write_text(args.get("content", ""))
   return {"result": True}

Options

Display: compact → Showing less information.

Comment with these commands to change:

Compact
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🧹 Nitpick comments (6)
tests/userspace/test_url_guard.py (1)

8-11: ⚡ Quick win

Consider adding IPv6 test coverage.

The tests cover IPv4 private/loopback addresses well, but IPv6 equivalents like ::1 (loopback) and fc00::/7 (unique local) are not tested. Since ip_address() handles IPv6 and attackers may use IPv6 literals to bypass guards, adding tests would strengthen confidence.

def test_blocks_ipv6_loopback_and_private():
    assert is_safe_public_url("http://[::1]/x") is False
    assert is_safe_public_url("http://[fc00::1]/x") is False
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/userspace/test_url_guard.py` around lines 8 - 11, The
test_blocks_loopback_and_private function only tests IPv4 addresses, leaving
IPv6 loopback and private address ranges untested. Add a new test function (for
example, test_blocks_ipv6_loopback_and_private) that tests the
is_safe_public_url function with IPv6 loopback addresses like [::1] and IPv6
unique local addresses like [fc00::1] to ensure the function properly rejects
these IPv6 equivalents of private ranges using the correct IPv6 literal notation
with brackets.
tinyagentos/userspace/broker.py (2)

36-52: ⚖️ Poor tradeoff

Missing key in args raises unhandled KeyError.

Several capability handlers access required keys directly (e.g., args["key"], args["table"], args["id"]) without guards. If the SDK or a malformed request omits these, the broker raises KeyError, resulting in a 500 response rather than a structured error.

Consider validating required args upfront or using .get() with explicit error handling:

if capability == "app.kv.get":
    key = args.get("key")
    if key is None:
        return {"error": "missing_arg", "arg": "key"}
    return {"result": await data_store.kv_get(app_id, key)}

This is low priority since the SDK controls the args, but improves robustness against malformed requests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/userspace/broker.py` around lines 36 - 52, The capability
handlers for app.kv.get, app.kv.set, app.kv.delete, app.table.insert,
app.table.query, and app.table.delete access required arguments directly from
the args dictionary without validation, which causes unhandled KeyError
exceptions if keys are missing. For each handler, replace direct dictionary
access (e.g., args["key"], args["table"], args["id"]) with args.get() calls and
add explicit None checks. When a required argument is missing, return a
structured error response with an "error" field and "arg" field indicating which
argument is missing, rather than letting the KeyError propagate.

97-99: ⚡ Quick win

URL-encoded path traversal may bypass the .. check.

The check ".." in path.split("/") catches literal .. segments but not URL-encoded variants like %2e%2e. While most backends don't decode these at the path level, some normalize URLs before routing. Consider also rejecting paths containing %2e or using urllib.parse.unquote before validation.

+from urllib.parse import unquote
 ...
     path = str(args.get("path", "/"))
-    if "://" in path or path.startswith("//") or ".." in path.split("/"):
+    decoded = unquote(path)
+    if "://" in path or path.startswith("//") or ".." in decoded.split("/"):
         return {"error": "invalid_path"}

The current risk is limited since the proxy targets only the app's own backend, but this strengthens defense-in-depth.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/userspace/broker.py` around lines 97 - 99, The path validation
logic in the broker.py file checks for literal ".." segments but does not
account for URL-encoded variants like %2e%2e which decode to path traversal
sequences. To fix this, decode the URL-encoded path using urllib.parse.unquote
before performing the existing validation checks for "://", "//", and ".." in
the path. This ensures that both literal and encoded path traversal attempts are
properly rejected when validating the path parameter from args.
tinyagentos/userspace/data_store.py (2)

54-60: 💤 Low value

table_insert return type may need a guard for type safety.

cur.lastrowid is typed as int | None in aiosqlite. While a successful INSERT always sets it, the return type annotation -> int could cause type checker warnings. A minor defensive assertion or cast would align the runtime guarantee with the type system.

     await self._db.commit()
-    return cur.lastrowid
+    assert cur.lastrowid is not None
+    return cur.lastrowid
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/userspace/data_store.py` around lines 54 - 60, The table_insert
method has a type safety issue where cur.lastrowid is typed as int | None in
aiosqlite, but the method's return type annotation is -> int. Add either a
defensive assertion or type cast on the cur.lastrowid value before returning it
to ensure the return type matches the annotation and satisfies type checkers.
This aligns the type system with the runtime guarantee that a successful INSERT
operation always sets lastrowid.

62-73: ⚖️ Poor tradeoff

In-memory filtering in table_query may not scale.

The current implementation fetches all rows for an app/table and filters in Python. For apps with many rows, this becomes inefficient. Consider translating simple where conditions to SQL WHERE clauses in a future iteration.

This is acceptable for phase 1 with small datasets.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/userspace/data_store.py` around lines 62 - 73, The `table_query`
method currently fetches all rows from the database and filters them in Python
using the `where` parameter, which is inefficient for large datasets. Modify the
method to translate the `where` dictionary into SQL WHERE clause conditions and
dynamically build the SELECT query to filter at the database level. When `where`
is provided, construct additional AND conditions for each key-value pair in the
dictionary, bind them as query parameters to prevent SQL injection, and execute
the query with these conditions included. Remove the Python-level filtering
logic that checks `where` conditions after fetching rows.
tinyagentos/userspace/sdk/taos-app-sdk.js (1)

7-14: 💤 Low value

Message listener doesn't verify origin.

The message event handler accepts replies from any origin. While the sandbox environment limits exposure and server-side validation provides the real security boundary, verifying e.origin matches the expected parent would add defense-in-depth.

window.addEventListener("message", (e) => {
  if (e.source !== parent) return;  // already good implicit check via parent ref
  // Optionally also check: if (e.origin !== expectedParentOrigin) return;
  ...
});

Low priority since the critical security is server-side.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/userspace/sdk/taos-app-sdk.js` around lines 7 - 14, The message
event listener in the window.addEventListener("message") callback does not
verify the origin of incoming messages, which is a security concern. Add origin
verification at the beginning of the handler before processing the message:
check that e.source matches parent (implicit check via parent reference) and
optionally verify that e.origin matches an expected parent origin value. If
either check fails, return early to reject the message. This adds a
defense-in-depth security layer by ensuring replies are only accepted from
trusted origins before resolving pending requests.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@desktop/src/apps/SandboxedAppWindow.tsx`:
- Around line 24-36: The code forwards msg.args directly from the postMessage
without validating that it is a plain object, which can cause the backend broker
to fail with 5xx errors when receiving non-object values. Before using msg.args
in the body JSON.stringify, normalize it by checking if it is actually a plain
object and default to an empty object if it is not, ensuring the broker always
receives valid object arguments at the bridge boundary.

In `@tests/userspace/test_routes.py`:
- Around line 43-44: The path traversal test in the client.get() call uses
literal `../../../` sequences which may be normalized and collapsed before
reaching the route handler, allowing the test to pass with a 404 without
actually validating the server-side bundle path guard. Replace the literal
traversal segments in the URL with URL-encoded equivalents (such as %2e%2e%2f
for ../) so that the encoded path reaches the server and the path guard
validation actually exercises the intended protection mechanism.

In `@tinyagentos/routes/userspace_apps.py`:
- Around line 125-127: The path containment checks at lines 127, 136, and 151
use non-portable string comparison with startswith(str(root) + "/"), which fails
on Windows systems due to different path separators. Replace all three instances
of this pattern with the pathlib-aware method is_relative_to(root), which safely
checks if app_dir is contained within root regardless of the operating system.
This will ensure the security checks work correctly across all platforms by
using proper path comparison semantics instead of string matching.
- Around line 83-93: The store.install() call persists the app before validating
the app_type, and then the NotImplementedError for container types is raised
afterward, leaving a partially installed app in storage. Move the container app
type validation check (comparing manifest["app_type"] to "container") to occur
before the store.install() call. This ensures that if the app is a container
type, the NotImplementedError is raised before any persistence happens,
maintaining install contract consistency.
- Around line 58-73: Add explicit error handling in this endpoint function to
catch two failure scenarios that currently result in 500 errors: (1) wrap the
await request.json() call in a try-except block to catch JSON parsing exceptions
and return a JSONResponse with a 400 status code for malformed JSON, and (2)
wrap the httpx client operations (the c.get(url) call and subsequent
raise_for_status() within the AsyncClient context manager) in a try-except block
to catch HTTP-related exceptions (such as connection errors, timeouts, or HTTP
errors) and return appropriate JSONResponse error responses with suitable status
codes instead of allowing these exceptions to propagate as unhandled 500 errors.

In `@tinyagentos/userspace/package.py`:
- Around line 63-64: The path containment checks using string startswith at
lines 63 and 70 are not robust across different operating systems due to
path-separator differences. Replace both instances of the startswith check with
Path.is_relative_to method calls to properly verify that app_dir is contained
within apps_root. This approach uses pathlib's built-in path handling to ensure
cross-platform compatibility instead of relying on string prefix matching.
- Around line 66-73: The path validation logic in the zip extraction loop allows
members to resolve to the app_dir root directory itself (such as a crafted
member like "."), which causes dest.write_bytes() to fail with IsADirectoryError
since it cannot write bytes to a directory. Modify the condition checking `if
not str(dest).startswith(str(app_dir) + "/") and dest != app_dir:` to properly
reject members that resolve exactly to app_dir by treating them as unsafe paths
and raising a PackageError, ensuring all file extraction targets are strictly
subdirectories of app_dir, not the root directory itself.
- Around line 19-24: After yaml.safe_load returns data on line 20, add a type
check to ensure data is a dictionary before attempting to access the .get()
method in the required field validation loop. If data is not a dictionary (e.g.,
it's a list or scalar), raise a PackageError with a message indicating the
manifest root must be a YAML object. This validation must occur before the for
loop that iterates over _REQUIRED to prevent AttributeError from bubbling up as
a 500 error instead of being properly caught as a PackageError.

In `@tinyagentos/userspace/sdk/taos-app-sdk.js`:
- Line 41: The net.fetch function sends a parameter named url to the broker, but
the broker at line 97 in broker.py expects a parameter named path. This mismatch
causes the url parameter to be ignored and all requests default to the backend
root path. Change the parameter name from url to path in the net.fetch call to
align with what the broker expects, ensuring the requested path is properly
passed through instead of being ignored.

---

Nitpick comments:
In `@tests/userspace/test_url_guard.py`:
- Around line 8-11: The test_blocks_loopback_and_private function only tests
IPv4 addresses, leaving IPv6 loopback and private address ranges untested. Add a
new test function (for example, test_blocks_ipv6_loopback_and_private) that
tests the is_safe_public_url function with IPv6 loopback addresses like [::1]
and IPv6 unique local addresses like [fc00::1] to ensure the function properly
rejects these IPv6 equivalents of private ranges using the correct IPv6 literal
notation with brackets.

In `@tinyagentos/userspace/broker.py`:
- Around line 36-52: The capability handlers for app.kv.get, app.kv.set,
app.kv.delete, app.table.insert, app.table.query, and app.table.delete access
required arguments directly from the args dictionary without validation, which
causes unhandled KeyError exceptions if keys are missing. For each handler,
replace direct dictionary access (e.g., args["key"], args["table"], args["id"])
with args.get() calls and add explicit None checks. When a required argument is
missing, return a structured error response with an "error" field and "arg"
field indicating which argument is missing, rather than letting the KeyError
propagate.
- Around line 97-99: The path validation logic in the broker.py file checks for
literal ".." segments but does not account for URL-encoded variants like %2e%2e
which decode to path traversal sequences. To fix this, decode the URL-encoded
path using urllib.parse.unquote before performing the existing validation checks
for "://", "//", and ".." in the path. This ensures that both literal and
encoded path traversal attempts are properly rejected when validating the path
parameter from args.

In `@tinyagentos/userspace/data_store.py`:
- Around line 54-60: The table_insert method has a type safety issue where
cur.lastrowid is typed as int | None in aiosqlite, but the method's return type
annotation is -> int. Add either a defensive assertion or type cast on the
cur.lastrowid value before returning it to ensure the return type matches the
annotation and satisfies type checkers. This aligns the type system with the
runtime guarantee that a successful INSERT operation always sets lastrowid.
- Around line 62-73: The `table_query` method currently fetches all rows from
the database and filters them in Python using the `where` parameter, which is
inefficient for large datasets. Modify the method to translate the `where`
dictionary into SQL WHERE clause conditions and dynamically build the SELECT
query to filter at the database level. When `where` is provided, construct
additional AND conditions for each key-value pair in the dictionary, bind them
as query parameters to prevent SQL injection, and execute the query with these
conditions included. Remove the Python-level filtering logic that checks `where`
conditions after fetching rows.

In `@tinyagentos/userspace/sdk/taos-app-sdk.js`:
- Around line 7-14: The message event listener in the
window.addEventListener("message") callback does not verify the origin of
incoming messages, which is a security concern. Add origin verification at the
beginning of the handler before processing the message: check that e.source
matches parent (implicit check via parent reference) and optionally verify that
e.origin matches an expected parent origin value. If either check fails, return
early to reject the message. This adds a defense-in-depth security layer by
ensuring replies are only accepted from trusted origins before resolving pending
requests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 04ac37ef-7593-4876-8f62-586c0669c808

📥 Commits

Reviewing files that changed from the base of the PR and between ac9785a and fc2671d.

📒 Files selected for processing (29)
  • desktop/src/apps/SandboxedAppWindow.tsx
  • desktop/src/apps/__tests__/SandboxedAppWindow.test.tsx
  • desktop/src/lib/__tests__/userspace-apps.test.ts
  • desktop/src/lib/userspace-apps.ts
  • desktop/src/registry/app-registry.ts
  • tests/userspace/__init__.py
  • tests/userspace/conftest.py
  • tests/userspace/test_broker.py
  • tests/userspace/test_broker_route.py
  • tests/userspace/test_data_store.py
  • tests/userspace/test_e2e.py
  • tests/userspace/test_immutability.py
  • tests/userspace/test_install_security.py
  • tests/userspace/test_package.py
  • tests/userspace/test_routes.py
  • tests/userspace/test_sdk_route.py
  • tests/userspace/test_store.py
  • tests/userspace/test_update_consent.py
  • tests/userspace/test_url_guard.py
  • tinyagentos/app.py
  • tinyagentos/routes/__init__.py
  • tinyagentos/routes/userspace_apps.py
  • tinyagentos/userspace/__init__.py
  • tinyagentos/userspace/broker.py
  • tinyagentos/userspace/data_store.py
  • tinyagentos/userspace/package.py
  • tinyagentos/userspace/sdk/taos-app-sdk.js
  • tinyagentos/userspace/store.py
  • tinyagentos/userspace/url_guard.py

Comment thread desktop/src/apps/SandboxedAppWindow.tsx
Comment thread tests/userspace/test_routes.py Outdated
Comment thread tinyagentos/routes/userspace_apps.py Outdated
Comment thread tinyagentos/routes/userspace_apps.py
Comment thread tinyagentos/routes/userspace_apps.py Outdated
Comment thread tinyagentos/userspace/package.py
Comment thread tinyagentos/userspace/package.py Outdated
Comment thread tinyagentos/userspace/package.py
Comment thread tinyagentos/userspace/sdk/taos-app-sdk.js Outdated
data = yaml.safe_load(text) or {}
except yaml.YAMLError as exc:
raise PackageError(f"invalid manifest YAML: {exc}") from exc
for key in _REQUIRED:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: Manifest fields are not type-validated before filesystem use

parse_manifest accepts any YAML scalar for id, name, version, or app_type. A numeric id reaches extract_package() and can raise TypeError; non-string path-like values can also bypass the intended manifest contract. Validate required fields are strings and validate id path safety before using them.

raise PackageError("container app requires container.ports as a non-empty list of ints")
data.setdefault("entry", "index.html")
data.setdefault("icon", "")
data.setdefault("permissions", [])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: permissions manifest value is not validated

permissions is defaulted without checking it is a list of strings. A string permission value is iterable as characters, and arbitrary YAML values can be serialized into the DB. Validate permissions as list[str] before returning the manifest.

# app_dir itself must stay within apps_root (defends against a crafted id)
if not str(app_dir).startswith(str(apps_root) + "/"):
raise PackageError(f"unsafe path in package: id {manifest['id']!r}")
app_dir.mkdir(parents=True, exist_ok=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: Reinstalling a package does not remove stale files

extract_package() creates app_dir and overwrites files in the zip, but leaves files that were removed from the new package. A reinstall can therefore keep old bundle code/assets accessible under the same app id. Clear the validated app directory before extracting, or atomically swap a fresh directory.

if not str(dest).startswith(str(app_dir) + "/") and dest != app_dir:
raise PackageError(f"unsafe path in package: {member}")
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(zf.read(member))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: Package extraction has no size limit

The route accepts arbitrary multipart or source_url bytes and extract_package() writes every zip member without bounding total or per-file size. A malicious package can exhaust disk space or create very large bundle files. Add explicit package/member size limits before extraction.

Comment thread tinyagentos/routes/userspace_apps.py Outdated
status_code=400,
)
async with httpx.AsyncClient(timeout=120, follow_redirects=False) as c:
resp = await c.get(url)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: source_url fetch errors are unhandled

resp.raise_for_status() can raise for 4xx/5xx source_url responses and is not caught, so installing from a URL can return a 500 instead of a controlled 400. Catch httpx.HTTPError and return a JSON error response.

(app_id, name, version, app_type, entry, icon,
permissions_requested, permissions_granted, enabled, installed_at)
VALUES (?,?,?,?,?,?,?,'[]',1,?)
ON CONFLICT(app_id) DO UPDATE SET

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: Reinstall preserves stale permission grants

The upsert updates permissions_requested but does not remove grants that are no longer requested. An app can keep previously granted gated capabilities after reinstalling with a narrower manifest. Reconcile permissions_granted to the intersection of existing grants and the new permissions_requested.

} catch {
result = { error: "broker_unreachable" };
}
iframe.contentWindow?.postMessage({ taosAppReply: msg.id, ...result }, "*");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: Broker replies are posted with targetOrigin "*"

The parent sends capability results to the iframe with "*". If the iframe navigates away, the new child origin could receive broker results. Use a fixed target origin for the known bundle origin when possible, or otherwise tighten the reply channel.

<iframe
ref={iframeRef}
title={appId}
src={`/api/userspace-apps/${encodeURIComponent(appId)}/bundle/index.html?app=${encodeURIComponent(appId)}`}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: Launcher hard-codes index.html despite manifest entry

The manifest stores entry, but SandboxedAppWindow always loads /bundle/index.html. Packages with a different entry file will install successfully but fail to launch. Pass the app entry path through the registry/manifest and use it in the iframe src.

const id = ++seq;
return new Promise((resolve) => {
pending.set(id, { resolve });
parent.postMessage({ taosApp: APP_ID, id, capability, args: args || {} }, "*");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: SDK posts capability requests with targetOrigin "*"

The userspace SDK sends broker requests to parent.postMessage(..., "*"). If the iframe ever navigates or is replaced, another origin could receive capability requests. Prefer a fixed targetOrigin for the parent host origin.

},
notify: (title, body) => call("app.notify", { title, body }),
// gated -- resolve to {error:"permission_denied"} if not granted
net: { fetch: (url, opts) => call("app.net", { url, opts }) },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: net.fetch(url) argument is ignored by the broker

The SDK exposes taos.net.fetch(url, opts), but broker.py's app.net handler ignores url and only accepts path. This makes the SDK API misleading and can cause unexpected failures for callers using the public method. Either pass url to the broker or remove/rename this method.

@kilo-code-bot

kilo-code-bot Bot commented Jun 16, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 19 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 18
SUGGESTION 0

Fix these issues in Kilo Cloud

Resolved from previous review: source_url error handling, container rejection before mutation, package/member size limits, manifest root type validation, root-resolving zip member rejection, broker jail-root write rejection, SDK net.fetch path alignment, and encoded bundle traversal test hardening.

Issue Details (click to expand)

CRITICAL

File Line Issue
tinyagentos/userspace/url_guard.py 41 IPv4-mapped IPv6 addresses such as ::ffff:127.0.0.1 may bypass private/loopback SSRF checks unless normalized to their IPv4-mapped value before classification.

WARNING

File Line Issue
tinyagentos/userspace/package.py 31 Required manifest fields are still not type-validated before filesystem use; numeric or path-like YAML values can cause runtime errors or bypass the intended manifest contract.
tinyagentos/userspace/package.py 54 permissions is still not validated as a list of strings before being stored or iterated.
tinyagentos/userspace/package.py 77 Manifest UTF-8 decode errors are not converted to PackageError; a valid zip with binary manifest content can become a 500.
tinyagentos/userspace/package.py 87 Reinstalling a package still overwrites zip files but does not remove stale files from previous versions.
tinyagentos/routes/userspace_apps.py 66 Install JSON body shape is not validated; valid non-object JSON such as [] or "x" can make body.get(...) raise AttributeError and return a 500.
tinyagentos/routes/userspace_apps.py 122 Permission grants are not validated against the app's requested permissions.
tinyagentos/routes/userspace_apps.py 151 Bundle files can be served without verifying the app exists and is enabled.
tinyagentos/routes/userspace_apps.py 195 Broker request body shape is not validated before dispatch.
tinyagentos/userspace/broker.py 31 Granted permissions are not validated as a list of known permission strings.
tinyagentos/userspace/broker.py 34 Capability args are not normalized/validated as object-shaped input.
tinyagentos/userspace/broker.py 101 app.net path validation misses encoded dot segments and backslash traversal.
tinyagentos/userspace/broker.py 105 Proxy HTTP method is not whitelisted.
tinyagentos/userspace/broker.py 107 Proxy headers are not shape/type-validated.
tinyagentos/userspace/url_guard.py 33 Invalid URL ports raise ValueError instead of returning False.
tinyagentos/userspace/store.py 42 Reinstall upsert preserves stale permission grants that are no longer requested.
desktop/src/apps/SandboxedAppWindow.tsx 49 Broker replies are posted to the iframe with targetOrigin "*".
desktop/src/apps/SandboxedAppWindow.tsx 59 Launcher hard-codes index.html despite the manifest entry field.
tinyagentos/userspace/sdk/taos-app-sdk.js 19 SDK posts capability requests to parent with targetOrigin "*".
Other Observations (not in diff)

Issues found in unchanged code that cannot receive inline comments:

File Line Issue
tinyagentos/userspace/url_guard.py 33 Invalid URL ports raise ValueError instead of returning False.
tinyagentos/userspace/url_guard.py 41 IPv4-mapped IPv6 addresses may bypass SSRF checks unless normalized.
tinyagentos/userspace/store.py 42 Reinstall upsert preserves stale permission grants that are no longer requested.
desktop/src/apps/SandboxedAppWindow.tsx 59 Launcher hard-codes index.html despite the manifest entry field.
tinyagentos/userspace/sdk/taos-app-sdk.js 19 SDK posts capability requests to parent with targetOrigin "*".
Files Reviewed (11 files including carried-forward unchanged files)
  • desktop/src/apps/SandboxedAppWindow.tsx - 2 issues
  • desktop/src/apps/__tests__/SandboxedAppWindow.test.tsx
  • tests/userspace/test_broker.py
  • tests/userspace/test_package.py
  • tests/userspace/test_routes.py
  • tinyagentos/routes/userspace_apps.py - 4 issues
  • tinyagentos/userspace/broker.py - 5 issues
  • tinyagentos/userspace/package.py - 4 issues
  • tinyagentos/userspace/sdk/taos-app-sdk.js - 1 issue
  • tinyagentos/userspace/url_guard.py - 2 issues carried forward
  • tinyagentos/userspace/store.py - 1 issue carried forward
Previous Review Summary (commit fc2671d)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit fc2671d)

Status: 21 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 20
SUGGESTION 0

Fix these issues in Kilo Cloud

Issue Details (click to expand)

CRITICAL

File Line Issue
tinyagentos/userspace/url_guard.py 41 IPv4-mapped IPv6 addresses such as ::ffff:127.0.0.1 may bypass private/loopback SSRF checks unless normalized to their IPv4-mapped value before classification.

WARNING

File Line Issue
tinyagentos/userspace/package.py 22 Required manifest fields are not type-validated before filesystem use; numeric or path-like YAML values can cause runtime errors or bypass the intended manifest contract.
tinyagentos/userspace/package.py 45 permissions is not validated as a list of strings before being stored or iterated.
tinyagentos/userspace/package.py 65 Reinstalling a package overwrites zip files but does not remove stale files from previous versions.
tinyagentos/userspace/package.py 73 Package extraction has no total or per-member size limit, creating a disk-exhaustion risk.
tinyagentos/routes/userspace_apps.py 71 source_url fetch HTTP errors are unhandled and can become 500 responses.
tinyagentos/routes/userspace_apps.py 89 Container packages are extracted and stored before the web-only rejection raises NotImplementedError.
tinyagentos/routes/userspace_apps.py 105 Permission grants are not validated against the app's requested permissions.
tinyagentos/routes/userspace_apps.py 134 Bundle files can be served without verifying the app exists and is enabled.
tinyagentos/routes/userspace_apps.py 178 Broker request body shape is not validated before dispatch.
tinyagentos/userspace/broker.py 31 Granted permissions are not validated as a list of known permission strings.
tinyagentos/userspace/broker.py 34 Capability args are not normalized/validated as object-shaped input.
tinyagentos/userspace/broker.py 97 app.net path validation misses encoded dot segments and backslash traversal.
tinyagentos/userspace/broker.py 101 Proxy HTTP method is not whitelisted.
tinyagentos/userspace/broker.py 103 Proxy headers are not shape/type-validated.
tinyagentos/userspace/url_guard.py 33 Invalid URL ports raise ValueError instead of returning False.
tinyagentos/userspace/store.py 42 Reinstall upsert preserves stale permission grants that are no longer requested.
desktop/src/apps/SandboxedAppWindow.tsx 41 Broker replies are posted to the iframe with targetOrigin "*".
desktop/src/apps/SandboxedAppWindow.tsx 51 Launcher hard-codes index.html despite the manifest entry field.
tinyagentos/userspace/sdk/taos-app-sdk.js 19 SDK posts capability requests to parent with targetOrigin "*".
tinyagentos/userspace/sdk/taos-app-sdk.js 41 net.fetch(url) exposes a url argument that the broker ignores.
Other Observations (not in diff)

No additional issues found outside diff hunks.

Files Reviewed (29 files)
  • desktop/src/apps/SandboxedAppWindow.tsx - 2 issues
  • desktop/src/apps/__tests__/SandboxedAppWindow.test.tsx
  • desktop/src/lib/__tests__/userspace-apps.test.ts
  • desktop/src/lib/userspace-apps.ts
  • desktop/src/registry/app-registry.ts
  • tests/userspace/__init__.py
  • tests/userspace/conftest.py
  • tests/userspace/test_broker.py
  • tests/userspace/test_broker_route.py
  • tests/userspace/test_data_store.py
  • tests/userspace/test_e2e.py
  • tests/userspace/test_immutability.py
  • tests/userspace/test_install_security.py
  • tests/userspace/test_package.py
  • tests/userspace/test_routes.py
  • tests/userspace/test_sdk_route.py
  • tests/userspace/test_store.py
  • tests/userspace/test_update_consent.py
  • tests/userspace/test_url_guard.py
  • tinyagentos/app.py
  • tinyagentos/routes/__init__.py
  • tinyagentos/routes/userspace_apps.py - 5 issues
  • tinyagentos/userspace/__init__.py
  • tinyagentos/userspace/broker.py - 5 issues
  • tinyagentos/userspace/data_store.py
  • tinyagentos/userspace/package.py - 4 issues
  • tinyagentos/userspace/sdk/taos-app-sdk.js - 2 issues
  • tinyagentos/userspace/store.py - 1 issue
  • tinyagentos/userspace/url_guard.py - 2 issues

Reviewed by nex-n2-pro:free · 761,420 tokens

jaylfc added 2 commits June 17, 2026 00:45
…il root (#89 P3a)

- package.py: cap declared uncompressed total, per-member size, and member count
  before extraction (zip-bomb defense, gitar Security finding)
- install_app: bound the upload/fetch read to a max size, return 413 if exceeded
- broker.py: app.files.write to the jail root or any existing dir now returns
  invalid_path instead of an uncaught IsADirectoryError (500); path containment
  uses is_relative_to
- tests for the zip-bomb cap and the jail-root write guard
body = await request.json()
except Exception:
return JSONResponse({"error": "invalid JSON body"}, status_code=400)
url = body.get("source_url")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: Install JSON body shape is not validated

Malformed JSON is handled, but a valid non-object JSON body such as [] or "x" will make body.get(...) raise AttributeError and return a 500. Validate that the JSON body is an object before reading source_url.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

f"package uncompressed size too large ({total_uncompressed} bytes)"
)
try:
manifest = parse_manifest(zf.read("manifest.yaml").decode("utf-8"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: Manifest UTF-8 decode errors are not converted to PackageError

zf.read("manifest.yaml").decode("utf-8") can raise UnicodeDecodeError for a valid zip with binary manifest content. The route only catches PackageError, so this becomes a 500 instead of a package validation error.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
tests/userspace/test_routes.py (1)

32-33: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

CSP assertion is too permissive for a security contract test.

Using or allows regressions where one critical directive disappears without failing the test.

Suggested fix
-    assert "frame-ancestors" in csp or "default-src" in csp
+    assert "frame-ancestors" in csp
+    assert "default-src" in csp
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/userspace/test_routes.py` around lines 32 - 33, The CSP assertion on
line 33 uses `or` logic which allows the test to pass if either
"frame-ancestors" or "default-src" is present, creating a security regression
risk where one critical directive could be removed without failing the test.
Change the assertion to require both directives to be present simultaneously by
replacing the `or` operator with `and` in the expression that validates the
content-security-policy header.
tinyagentos/userspace/package.py (1)

76-79: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle non-UTF8 manifest.yaml as PackageError instead of 500.

A malformed archive with non-UTF8 manifest.yaml raises UnicodeDecodeError here and bypasses the route’s PackageError→400 path.

Suggested fix
-    try:
-        manifest = parse_manifest(zf.read("manifest.yaml").decode("utf-8"))
-    except KeyError as exc:
-        raise PackageError("manifest.yaml missing from package") from exc
+    try:
+        manifest_bytes = zf.read("manifest.yaml")
+    except KeyError as exc:
+        raise PackageError("manifest.yaml missing from package") from exc
+    try:
+        manifest = parse_manifest(manifest_bytes.decode("utf-8"))
+    except UnicodeDecodeError as exc:
+        raise PackageError("manifest.yaml must be UTF-8 text") from exc
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/userspace/package.py` around lines 76 - 79, The try-except block
in the manifest reading code currently only catches KeyError (when manifest.yaml
is missing), but it does not catch UnicodeDecodeError which can occur when
manifest.yaml contains non-UTF8 data. This causes the UnicodeDecodeError to
propagate uncaught instead of being converted to PackageError. Modify the except
clause to catch both KeyError and UnicodeDecodeError, ensuring both cases raise
PackageError with appropriate messages so they are properly handled as 400
errors instead of 500 errors.
tinyagentos/routes/userspace_apps.py (2)

149-153: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Constrain app_id before serving bundle files.

Line 153 only proves target is inside the already-resolved root; it does not prove root is a real app directory under the apps root. With app_id such as . or .. via path-as-is/encoded requests, this can expose another app’s files or data-dir files. Check the installed/enabled app first and validate root against the apps root.

Proposed fix
 `@router.get`("/api/userspace-apps/{app_id}/bundle/{path:path}")
 async def serve_bundle(request: Request, app_id: str, path: str):
-    root = (_apps_root(request) / app_id).resolve()
+    app = await request.app.state.userspace_apps.get(app_id)
+    if app is None or not app["enabled"]:
+        return JSONResponse({"error": "not found"}, status_code=404)
+
+    apps_root = _apps_root(request).resolve()
+    root = (apps_root / app_id).resolve()
+    if not root.is_relative_to(apps_root) or root == apps_root:
+        return JSONResponse({"error": "not found"}, status_code=404)
+
     target = (root / path).resolve()
     if not target.is_relative_to(root) or target == root or not target.is_file():
         return JSONResponse({"error": "not found"}, status_code=404)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/routes/userspace_apps.py` around lines 149 - 153, The
serve_bundle function validates that the target file is within the root
directory, but does not validate that the root directory itself is a legitimate
app directory under the apps root. This allows an attacker to use path traversal
in app_id (such as "." or "..") to access other apps' files. Before resolving
and serving the bundle, validate that app_id corresponds to an installed/enabled
app, and verify that the resolved root path (created from _apps_root(request) /
app_id) is actually within the apps root directory by checking that root is
relative to _apps_root(request). Add this validation logic before attempting to
serve the target file.

71-79: ⚠️ Potential issue | 🔴 Critical

DNS rebinding can bypass the SSRF guard — pin the resolved IP before fetching.

is_safe_public_url(url) validates hostnames by resolving them once, but httpx.AsyncClient.get(url) resolves the hostname again at request time. An attacker controlling DNS can return a public IP during validation and a private address during the actual fetch, bypassing the guard. Mitigate by either:

  1. Caching the validated IP and passing it directly to httpx (via custom socket or IP literal in URL)
  2. Enforcing egress filtering that blocks private IPs at the network layer
  3. Using a custom httpx Transport with a pinned resolver that reuses the validated IP
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/routes/userspace_apps.py` around lines 71 - 79, The DNS rebinding
vulnerability occurs because is_safe_public_url validates the hostname by
resolving it once, but httpx.AsyncClient.get resolves it again during the actual
fetch, allowing an attacker to swap the IP between validation and request. To
fix this, modify the code to capture and reuse the validated IP address instead
of re-resolving the hostname. Either resolve the URL's hostname before calling
is_safe_public_url, pass the validated IP directly to the httpx client (by using
it in the URL or via a custom transport that binds to that specific IP), or
implement a custom httpx Transport that pins the resolver to the previously
validated IP. Ensure the resolved IP is used for the httpx request rather than
allowing httpx to perform its own DNS resolution.
♻️ Duplicate comments (4)
tinyagentos/routes/userspace_apps.py (4)

195-198: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate broker request shape before dispatch.

Malformed JSON, non-object JSON, non-string capability, or non-object args can still reach .get()/handle_capability() and produce 500s instead of a controlled broker error.

Proposed fix
-    body = await request.json()
+    try:
+        body = await request.json()
+    except ValueError:
+        return JSONResponse({"error": "invalid JSON body"}, status_code=400)
+    if not isinstance(body, dict):
+        return JSONResponse({"error": "invalid_args"}, status_code=400)
+    capability = body.get("capability", "")
+    args = body.get("args") or {}
+    if not isinstance(capability, str) or not isinstance(args, dict):
+        return JSONResponse({"error": "invalid_args"}, status_code=400)
     out = await handle_capability(
-        app_id, body.get("capability", ""), body.get("args") or {},
+        app_id, capability, args,
         granted=app["permissions_granted"],
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/routes/userspace_apps.py` around lines 195 - 198, After the await
request.json() call that assigns to body, add validation to ensure body is a
dictionary, capability is a string, and args is a dictionary (if provided). If
validation fails, return a controlled error response instead of allowing
malformed values to reach the handle_capability() function. This prevents 500
errors from invalid types and ensures proper error handling for the broker
request.

91-100: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Reject containers before extraction mutates the app directory.

This now avoids the DB insert, but extract_package() has already written into apps_root/{manifest["id"]} before Line 96. A rejected container install can still leave orphan files or overwrite an existing app’s bundle before returning 501. Peek/parse the manifest before extraction, or extract into a temporary staging directory and only move it into place after the web-only check passes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/routes/userspace_apps.py` around lines 91 - 100, The
extract_package() function is being called before validating that the app_type
is not a container, which means files are already written to the apps_root
directory before the 501 rejection is returned, potentially leaving orphaned
files or overwriting existing apps. Restructure the code to either parse the
manifest metadata before calling extract_package() to validate the app_type
constraint upfront, or modify extract_package() to extract into a temporary
staging directory first, then only move the extracted files into the permanent
apps_root location after confirming the container check passes.

119-123: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate grants against the app’s requested permissions.

This endpoint still accepts arbitrary granted values and writes them into the authorization set used by the broker. Fetch the app row, require a list of strings, and reject any permission not present in permissions_requested.

Proposed fix
 `@router.post`("/api/userspace-apps/{app_id}/permissions")
 async def set_permissions(request: Request, app_id: str):
-    body = await request.json()
-    await request.app.state.userspace_apps.set_permissions_granted(app_id, body.get("granted", []))
+    try:
+        body = await request.json()
+    except ValueError:
+        return JSONResponse({"error": "invalid JSON body"}, status_code=400)
+    if not isinstance(body, dict):
+        return JSONResponse({"error": "invalid JSON body"}, status_code=400)
+
+    app = await request.app.state.userspace_apps.get(app_id)
+    if app is None:
+        return JSONResponse({"error": "app not found"}, status_code=404)
+
+    granted = body.get("granted", [])
+    if not isinstance(granted, list) or not all(isinstance(p, str) for p in granted):
+        return JSONResponse({"error": "granted must be a list of permissions"}, status_code=400)
+
+    requested = set(app["permissions_requested"])
+    if any(p not in requested for p in granted):
+        return JSONResponse({"error": "cannot grant unrequested permissions"}, status_code=400)
+
+    await request.app.state.userspace_apps.set_permissions_granted(app_id, granted)
     return {"status": "ok"}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/routes/userspace_apps.py` around lines 119 - 123, The
set_permissions endpoint accepts arbitrary permission values without validation.
Fetch the app record using app_id to retrieve its permissions_requested field,
then validate that the granted list from the request body contains only strings
and that each granted permission exists in the app's permissions_requested.
Reject the request with an appropriate error response if the granted list is not
a list of strings or if any permission is not in the app's requested
permissions, only proceeding to call set_permissions_granted if all validations
pass.

77-90: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Stream source_url downloads before buffering.

Line 79/81 still reads the full upstream response into memory before Line 89 enforces _MAX_PACKAGE_BYTES; a public attacker-controlled URL can return a huge body and bypass the intended memory cap until after allocation. This is the residual fetch-side part of the previous size-limit finding.

Proposed fix
         try:
             async with httpx.AsyncClient(timeout=120, follow_redirects=False) as c:
-                resp = await c.get(url)
-                resp.raise_for_status()
-                data = resp.content
+                async with c.stream("GET", url) as resp:
+                    resp.raise_for_status()
+                    chunks: list[bytes] = []
+                    total = 0
+                    async for chunk in resp.aiter_bytes():
+                        total += len(chunk)
+                        if total > _MAX_PACKAGE_BYTES:
+                            return JSONResponse({"error": "package too large"}, status_code=413)
+                        chunks.append(chunk)
+                    data = b"".join(chunks)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/routes/userspace_apps.py` around lines 77 - 90, The current
implementation reads the entire upstream response into memory via resp.content
on line 81 before checking the _MAX_PACKAGE_BYTES limit on line 89, which allows
an attacker to exhaust memory with a huge response body. Instead of buffering
the full response content at once, stream the response data incrementally and
apply the size limit check as data arrives. Modify the httpx request to read the
response in chunks (using iter_bytes or iter_raw), accumulate the data while
tracking the total size, and raise an exception or return the 413 error response
as soon as the size exceeds _MAX_PACKAGE_BYTES, preventing the full body from
being loaded into memory.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/userspace/test_routes.py`:
- Around line 91-103: The test_container_install_rejected_with_no_stored_state
function only validates that no database row is stored but does not verify that
the rejected container package files are properly cleaned up from the filesystem
and cannot be served. After asserting that no app row exists in the database,
add additional assertions to verify that the extracted/installed container files
are not accessible or servable from the /bundle/... endpoint, ensuring complete
rollback of both database and filesystem state when container installation is
rejected.

In `@tinyagentos/routes/userspace_apps.py`:
- Around line 62-66: The exception handler in the try-except block around await
request.json() is too broad with its generic Exception catch, and it does not
validate that the JSON body is actually a dictionary before calling .get() on
it. Replace the broad except Exception with more specific exceptions like
JSONDecodeError or ValueError that are raised during JSON parsing failures.
Additionally, after obtaining the body from await request.json(), add validation
to check that body is a dictionary before attempting to access
body.get("source_url"). If body is not a dict, return a JSONResponse with a 400
status code indicating that the JSON body must be an object. This prevents
AttributeError exceptions when the parsed JSON is a list or string and ensures
only properly formatted JSON objects are processed.

---

Outside diff comments:
In `@tests/userspace/test_routes.py`:
- Around line 32-33: The CSP assertion on line 33 uses `or` logic which allows
the test to pass if either "frame-ancestors" or "default-src" is present,
creating a security regression risk where one critical directive could be
removed without failing the test. Change the assertion to require both
directives to be present simultaneously by replacing the `or` operator with
`and` in the expression that validates the content-security-policy header.

In `@tinyagentos/routes/userspace_apps.py`:
- Around line 149-153: The serve_bundle function validates that the target file
is within the root directory, but does not validate that the root directory
itself is a legitimate app directory under the apps root. This allows an
attacker to use path traversal in app_id (such as "." or "..") to access other
apps' files. Before resolving and serving the bundle, validate that app_id
corresponds to an installed/enabled app, and verify that the resolved root path
(created from _apps_root(request) / app_id) is actually within the apps root
directory by checking that root is relative to _apps_root(request). Add this
validation logic before attempting to serve the target file.
- Around line 71-79: The DNS rebinding vulnerability occurs because
is_safe_public_url validates the hostname by resolving it once, but
httpx.AsyncClient.get resolves it again during the actual fetch, allowing an
attacker to swap the IP between validation and request. To fix this, modify the
code to capture and reuse the validated IP address instead of re-resolving the
hostname. Either resolve the URL's hostname before calling is_safe_public_url,
pass the validated IP directly to the httpx client (by using it in the URL or
via a custom transport that binds to that specific IP), or implement a custom
httpx Transport that pins the resolver to the previously validated IP. Ensure
the resolved IP is used for the httpx request rather than allowing httpx to
perform its own DNS resolution.

In `@tinyagentos/userspace/package.py`:
- Around line 76-79: The try-except block in the manifest reading code currently
only catches KeyError (when manifest.yaml is missing), but it does not catch
UnicodeDecodeError which can occur when manifest.yaml contains non-UTF8 data.
This causes the UnicodeDecodeError to propagate uncaught instead of being
converted to PackageError. Modify the except clause to catch both KeyError and
UnicodeDecodeError, ensuring both cases raise PackageError with appropriate
messages so they are properly handled as 400 errors instead of 500 errors.

---

Duplicate comments:
In `@tinyagentos/routes/userspace_apps.py`:
- Around line 195-198: After the await request.json() call that assigns to body,
add validation to ensure body is a dictionary, capability is a string, and args
is a dictionary (if provided). If validation fails, return a controlled error
response instead of allowing malformed values to reach the handle_capability()
function. This prevents 500 errors from invalid types and ensures proper error
handling for the broker request.
- Around line 91-100: The extract_package() function is being called before
validating that the app_type is not a container, which means files are already
written to the apps_root directory before the 501 rejection is returned,
potentially leaving orphaned files or overwriting existing apps. Restructure the
code to either parse the manifest metadata before calling extract_package() to
validate the app_type constraint upfront, or modify extract_package() to extract
into a temporary staging directory first, then only move the extracted files
into the permanent apps_root location after confirming the container check
passes.
- Around line 119-123: The set_permissions endpoint accepts arbitrary permission
values without validation. Fetch the app record using app_id to retrieve its
permissions_requested field, then validate that the granted list from the
request body contains only strings and that each granted permission exists in
the app's permissions_requested. Reject the request with an appropriate error
response if the granted list is not a list of strings or if any permission is
not in the app's requested permissions, only proceeding to call
set_permissions_granted if all validations pass.
- Around line 77-90: The current implementation reads the entire upstream
response into memory via resp.content on line 81 before checking the
_MAX_PACKAGE_BYTES limit on line 89, which allows an attacker to exhaust memory
with a huge response body. Instead of buffering the full response content at
once, stream the response data incrementally and apply the size limit check as
data arrives. Modify the httpx request to read the response in chunks (using
iter_bytes or iter_raw), accumulate the data while tracking the total size, and
raise an exception or return the 413 error response as soon as the size exceeds
_MAX_PACKAGE_BYTES, preventing the full body from being loaded into memory.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b58b358d-7e69-482c-a82a-2a585d028102

📥 Commits

Reviewing files that changed from the base of the PR and between fc2671d and 0b990a0.

📒 Files selected for processing (10)
  • desktop/node_modules
  • desktop/src/apps/SandboxedAppWindow.tsx
  • desktop/src/apps/__tests__/SandboxedAppWindow.test.tsx
  • tests/userspace/test_broker.py
  • tests/userspace/test_package.py
  • tests/userspace/test_routes.py
  • tinyagentos/routes/userspace_apps.py
  • tinyagentos/userspace/broker.py
  • tinyagentos/userspace/package.py
  • tinyagentos/userspace/sdk/taos-app-sdk.js
✅ Files skipped from review due to trivial changes (1)
  • desktop/node_modules
🚧 Files skipped from review as they are similar to previous changes (5)
  • desktop/src/apps/tests/SandboxedAppWindow.test.tsx
  • desktop/src/apps/SandboxedAppWindow.tsx
  • tinyagentos/userspace/sdk/taos-app-sdk.js
  • tests/userspace/test_broker.py
  • tinyagentos/userspace/broker.py

Comment on lines +91 to +103
async def test_container_install_rejected_with_no_stored_state(client):
# Finding 3: installing a container package must return 501 before
# persisting anything -- no app row and no extracted directory must remain.
r = await client.post(
"/api/userspace-apps/install",
files={"package": ("ctapp.taosapp", _container_zip(), "application/zip")},
)
assert r.status_code == 501
assert "container" in r.json()["error"].lower()

# No app row stored.
rows = (await client.get("/api/userspace-apps")).json()
assert all(a["app_id"] != "ctapp" for a in rows)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

The container-rejection test does not validate filesystem rollback/no-serve behavior.

This test currently checks DB state only, so it can miss a case where rejected container package files are still servable from /bundle/....

Suggested fix
 async def test_container_install_rejected_with_no_stored_state(client):
@@
     rows = (await client.get("/api/userspace-apps")).json()
     assert all(a["app_id"] != "ctapp" for a in rows)
+
+    # Rejected install must not leave bundle files accessible.
+    r = await client.get("/api/userspace-apps/ctapp/bundle/index.html")
+    assert r.status_code == 404
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/userspace/test_routes.py` around lines 91 - 103, The
test_container_install_rejected_with_no_stored_state function only validates
that no database row is stored but does not verify that the rejected container
package files are properly cleaned up from the filesystem and cannot be served.
After asserting that no app row exists in the database, add additional
assertions to verify that the extracted/installed container files are not
accessible or servable from the /bundle/... endpoint, ensuring complete rollback
of both database and filesystem state when container installation is rejected.

Comment on lines +62 to +66
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "invalid JSON body"}, status_code=400)
url = body.get("source_url")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reject non-object JSON bodies and narrow the exception.

await request.json() can successfully return a list/string, so Line 66 can still 500 on .get; except Exception also masks unrelated failures. Catch JSON decode/value errors and verify body is a dict before reading source_url.

Proposed fix
-        try:
-            body = await request.json()
-        except Exception:
+        try:
+            body = await request.json()
+        except ValueError:
             return JSONResponse({"error": "invalid JSON body"}, status_code=400)
+        if not isinstance(body, dict):
+            return JSONResponse({"error": "invalid JSON body"}, status_code=400)
         url = body.get("source_url")
🧰 Tools
🪛 Ruff (0.15.17)

[warning] 64-64: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/routes/userspace_apps.py` around lines 62 - 66, The exception
handler in the try-except block around await request.json() is too broad with
its generic Exception catch, and it does not validate that the JSON body is
actually a dictionary before calling .get() on it. Replace the broad except
Exception with more specific exceptions like JSONDecodeError or ValueError that
are raised during JSON parsing failures. Additionally, after obtaining the body
from await request.json(), add validation to check that body is a dictionary
before attempting to access body.get("source_url"). If body is not a dict,
return a JSONResponse with a 400 status code indicating that the JSON body must
be an object. This prevents AttributeError exceptions when the parsed JSON is a
list or string and ensures only properly formatted JSON objects are processed.

Source: Linters/SAST tools

@jaylfc jaylfc merged commit 035b017 into dev Jun 17, 2026
7 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in TinyAgentOS Roadmap Jun 17, 2026
@jaylfc jaylfc deleted the feat/userspace-runtime branch June 17, 2026 00:10
jaylfc added a commit that referenced this pull request Jun 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

1 participant