Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
b37cc8e
perf(frontend): gate access plans/roles queries on full auth context
ardaerzin Jul 6, 2026
5862651
chore(frontend): defer project-wide list/catalog queries behind lazy …
ardaerzin Jun 23, 2026
558af81
perf(frontend): lazy-gate the playground workflow-reference bridge
ardaerzin Jul 7, 2026
43f3546
perf(frontend): hoist agent drawer data hooks into open-gated content
ardaerzin Jul 8, 2026
effc55b
perf(frontend): persist agent catalogs, inspect & type across reloads
ardaerzin Jul 8, 2026
dfcea35
perf(frontend): detect agent mode early to remove load-time chrome flash
ardaerzin Jul 8, 2026
1d8a23c
perf(frontend): prefetch agent catalogs in parallel with the config w…
ardaerzin Jul 8, 2026
2d2c2d9
chore: gitignore skills-lock.json
ardaerzin Jul 8, 2026
5a52018
feat(frontend): background trace-summary hydration
ardaerzin Jul 8, 2026
d0bc197
perf(frontend): demote secondary playground requests to low network p…
ardaerzin Jul 8, 2026
68af68d
perf(frontend): demote entitlement/billing bootstrap, fail fast on ga…
ardaerzin Jul 8, 2026
d608473
perf(frontend): demote sidebar/picker workflow queries off the playgr…
ardaerzin Jul 8, 2026
f17e889
perf(frontend): dedup the current-app revision detail against the rev…
ardaerzin Jul 8, 2026
add38e8
perf(frontend): defer entitlement/billing bootstrap queries to browse…
ardaerzin Jul 8, 2026
fa56d9f
perf(frontend): dedup the selected-org detail double-fetch on cold load
ardaerzin Jul 8, 2026
dee2323
feat(frontend): agent playground loading skeletons
ardaerzin Jul 8, 2026
75f1b06
fix(frontend): graceful trace-pending states in agent chat messages
ardaerzin Jul 8, 2026
fa8d2e2
Merge remote-tracking branch 'origin/big-agents' into fe-chore/reques…
ardaerzin Jul 8, 2026
057d606
fix(frontend): re-enable Crisp chat widget on cloud
ardaerzin Jul 8, 2026
61ee874
fix(frontend): keyboard-accessible evaluator rows, prettier formatting
ardaerzin Jul 8, 2026
8345a1d
Merge branch 'big-agents' into fe-chore/request-optimizations-onbig
ardaerzin Jul 8, 2026
a671043
perf(frontend): code-split prompt generation UIs, comparison view, an…
ardaerzin Jul 8, 2026
ee194f1
Merge remote-tracking branch 'origin/big-agents' into fe-chore/reques…
ardaerzin Jul 8, 2026
1f42a14
perf(frontend): code-split agent chat panel regions (session bar, rai…
ardaerzin Jul 8, 2026
1d6c1b5
feat(frontend): humanize agent tool-step names in chat
ardaerzin Jul 9, 2026
70c17f2
perf(frontend): kill the warm client-side re-entry flash
ardaerzin Jul 9, 2026
438d1c4
perf(frontend): idle-warm the code-split agent-template control
ardaerzin Jul 9, 2026
8fe701d
feat(frontend): persist chat session state across pane remounts; resi…
ardaerzin Jul 9, 2026
0444f36
polish(frontend): agent chat + revision UI details
ardaerzin Jul 9, 2026
556866d
feat(frontend): seamless agent self-commit transition + changed-secti…
ardaerzin Jul 9, 2026
752211f
perf(frontend): frame-split the agent chat panel; region skeletons + …
ardaerzin Jul 9, 2026
873d63a
feat(frontend): surface awaiting-input state on session status dots
ardaerzin Jul 9, 2026
c979c8f
Merge remote-tracking branch 'origin/big-agents' into fe-chore/reques…
ardaerzin Jul 9, 2026
18221c9
feat(frontend): keep the streaming (responding) colour on the active …
ardaerzin Jul 9, 2026
bc1d68c
fix(frontend): agent-update notice in the config header + distinct in…
ardaerzin Jul 9, 2026
f8c56d6
fix(frontend): keep agent config sections mounted across revision swi…
ardaerzin Jul 10, 2026
55b7243
feat(frontend): agent self-commit notice as a bottom-pinned banner
ardaerzin Jul 10, 2026
ad60818
fix(frontend): persist agent type at every revision learn point
ardaerzin Jul 10, 2026
1f67ce2
fix(frontend): tolerate non-numeric revision versions in workflowSchema
ardaerzin Jul 10, 2026
7302280
feat(frontend): transition section-header indicator states
ardaerzin Jul 10, 2026
d3d336c
fix(api,frontend): scope OAuth completion to its connection so multi-…
ardaerzin Jul 10, 2026
3ac6b62
perf(frontend): byte-budget the inspect disk cache so it can't starve…
ardaerzin Jul 10, 2026
ffa9c4c
fix(frontend): settle the connect widget out of its 'Connecting…' sta…
ardaerzin Jul 10, 2026
d89edc8
fix(frontend): render a runner-deferred connection as pending, not a …
ardaerzin Jul 10, 2026
c25d408
fix(runner): don't let a deferred sibling connection satisfy its own …
ardaerzin Jul 10, 2026
1235a2b
fix(frontend): release the chat queue on an orphaned post-approval re…
ardaerzin Jul 10, 2026
30ffc84
Merge remote-tracking branch 'origin/big-agents' into fe-chore/reques…
ardaerzin Jul 10, 2026
2c3275f
fix(api): escape the OAuth completion payload embedded in the inline …
ardaerzin Jul 10, 2026
565bbd1
fix(frontend): clear per-session chat ephemera on delete/reset; cance…
ardaerzin Jul 10, 2026
9335fcf
test(frontend): update entities unit mocks for the low-priority clien…
ardaerzin Jul 10, 2026
85705c1
Merge remote-tracking branch 'origin/big-agents' into fe-chore/reques…
ardaerzin Jul 10, 2026
a3aa077
fix(frontend): latch agent config geometry across revision switches
ardaerzin Jul 10, 2026
295454b
fix(frontend): adopt the onboarding conversation into the app's chat …
ardaerzin Jul 10, 2026
05fe10b
fix(frontend): Enter submits the onboarding composer
ardaerzin Jul 10, 2026
6538e57
fix(runner): park a widget for every connection requested in one turn
ardaerzin Jul 10, 2026
5a46976
Merge remote-tracking branch 'origin/big-agents' into fe-chore/reques…
ardaerzin Jul 10, 2026
5f909e2
Merge remote-tracking branch 'origin/big-agents' into fe-chore/reques…
ardaerzin Jul 10, 2026
2e1c3c1
fix(frontend): give each connect popup a unique window name
ardaerzin Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,6 @@ hosting/kubernetes/**/values.*.yaml
# IDE/LSP config (local tooling)
pyrightconfig.json
.gstack/

# Agent skills lockfile (npx skills add)
skills-lock.json
80 changes: 62 additions & 18 deletions api/oss/src/apis/fastapi/tools/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import re
from datetime import datetime, timezone
from functools import wraps
from typing import List, Optional
from typing import Any, Dict, List, Optional
from urllib.parse import urlsplit
from uuid import UUID, uuid4

Expand Down Expand Up @@ -894,13 +894,32 @@ async def callback_connection(
state: Optional[str] = Query(default=None),
) -> HTMLResponse:
"""Handle OAuth callback from Composio."""
# Decode the HMAC-signed state up front to recover BOTH the project scope and the
# connection identity. The identity tags every card (success or failure) so the
# opener can tell WHICH connect flow finished — the playground can have several
# live at once (see ConnectToolWidget), and an untagged completion would settle
# all of them.
state_payload = (
decode_oauth_state(state, secret_key=env.agenta.crypt_key)
if state
else None
)
if not state:
log.warning("OAuth callback received without state token")
elif state_payload is None:
log.warning("OAuth callback: invalid or expired state token")
state_slug = state_payload.get("slug") if state_payload else None
state_integration = state_payload.get("integration") if state_payload else None

if error_message or status == "failed":
log.error("OAuth callback failed: status=%s", status)
return HTMLResponse(
status_code=400,
content=_oauth_card(
success=False,
error=error_message or "Authorization failed. Please try again.",
slug=state_slug,
integration_key=state_integration,
),
)

Expand All @@ -910,31 +929,28 @@ async def callback_connection(
content=_oauth_card(
success=False,
error="Missing connection identifier. Please try again.",
slug=state_slug,
integration_key=state_integration,
),
)

# Decode HMAC-signed state to recover project scope. Activation is
# project-scoped, so a missing/invalid state is fatal — we never activate
# without a resolved project_id.
# Activation is project-scoped, so a missing/invalid state is fatal — we never
# activate without a resolved project_id.
project_id: Optional[UUID] = None
if state:
payload = decode_oauth_state(state, secret_key=env.agenta.crypt_key)
if payload is None:
log.warning("OAuth callback: invalid or expired state token")
else:
try:
project_id = UUID(payload["project_id"])
except (KeyError, ValueError):
log.warning("OAuth callback state missing or invalid project_id")
else:
log.warning("OAuth callback received without state token")
if state_payload is not None:
try:
project_id = UUID(state_payload["project_id"])
except (KeyError, ValueError):
log.warning("OAuth callback state missing or invalid project_id")

if project_id is None:
return HTMLResponse(
status_code=400,
content=_oauth_card(
success=False,
error="Connection could not be activated. Please try again.",
slug=state_slug,
integration_key=state_integration,
),
)

Expand All @@ -954,6 +970,8 @@ async def callback_connection(
content=_oauth_card(
success=False,
error="Connection could not be activated. Please try again.",
slug=state_slug,
integration_key=state_integration,
),
)
except Exception:
Expand All @@ -963,6 +981,8 @@ async def callback_connection(
content=_oauth_card(
success=False,
error="An internal error occurred. Please try again.",
slug=state_slug,
integration_key=state_integration,
),
)

Expand Down Expand Up @@ -992,6 +1012,8 @@ async def callback_connection(
integration_logo=integration_logo,
integration_url=integration_url,
agenta_url=env.agenta.web_url,
slug=conn.slug,
integration_key=conn.integration_key,
),
)

Expand Down Expand Up @@ -1428,6 +1450,13 @@ async def _emit_data_event(
# ---------------------------------------------------------------------------


def _json_for_inline_script(value: Any) -> str:
# `json.dumps` leaves `<` intact, so a value containing `</script>` would terminate
# the inline <script> block (the HTML parser ignores JS string boundaries). Escape it
# for every JSON payload embedded in a script tag.
return json.dumps(value).replace("<", "\\u003c")


def _oauth_card(
*,
success: bool,
Expand All @@ -1436,6 +1465,8 @@ def _oauth_card(
integration_url: Optional[str] = None,
agenta_url: Optional[str] = None,
error: Optional[str] = None,
slug: Optional[str] = None,
integration_key: Optional[str] = None,
) -> str:
# HTML-escape all provider-supplied strings before interpolation.
safe_label = html_lib.escape(integration_label) if integration_label else None
Expand All @@ -1448,7 +1479,19 @@ def _oauth_card(
parsed_agenta_url = urlsplit(agenta_url)
if parsed_agenta_url.scheme and parsed_agenta_url.netloc:
agenta_origin = f"{parsed_agenta_url.scheme}://{parsed_agenta_url.netloc}"
agenta_post_message_origin_js = json.dumps(agenta_origin)
agenta_post_message_origin_js = _json_for_inline_script(agenta_origin)

# Tag the completion message with the connection's identity so the opener can tell
# WHICH connection finished. The playground can have several connect flows live at
# once (an agent may request multiple connections in one turn); without this, every
# open connect widget would settle on the first completion. Absent keys keep older
# openers working (they ignore the extra fields).
oauth_complete_payload: Dict[str, str] = {"type": "tools:oauth:complete"}
if slug:
oauth_complete_payload["slug"] = slug
if integration_key:
oauth_complete_payload["integration"] = integration_key
oauth_complete_message_js = _json_for_inline_script(oauth_complete_payload)

accent = "#16a34a" if success else "#dc2626"
agenta_favicon = (
Expand Down Expand Up @@ -1630,6 +1673,7 @@ def _oauth_card(
</div>
<script>
const AGENTA_POST_MESSAGE_ORIGIN = {agenta_post_message_origin_js};
const AGENTA_OAUTH_COMPLETE = {oauth_complete_message_js};

function returnToAgenta(event) {{
if (event) {{
Expand All @@ -1638,7 +1682,7 @@ def _oauth_card(

try {{
if (window.opener && !window.opener.closed && AGENTA_POST_MESSAGE_ORIGIN) {{
window.opener.postMessage({{type: "tools:oauth:complete"}}, AGENTA_POST_MESSAGE_ORIGIN);
window.opener.postMessage(AGENTA_OAUTH_COMPLETE, AGENTA_POST_MESSAGE_ORIGIN);
window.opener.focus();
}}
}} catch (_e) {{
Expand All @@ -1654,7 +1698,7 @@ def _oauth_card(
}}

if (window.opener && AGENTA_POST_MESSAGE_ORIGIN) {{
window.opener.postMessage({{type: "tools:oauth:complete"}}, AGENTA_POST_MESSAGE_ORIGIN);
window.opener.postMessage(AGENTA_OAUTH_COMPLETE, AGENTA_POST_MESSAGE_ORIGIN);
}}

const countdownEl = document.getElementById("auto-return-text");
Expand Down
4 changes: 4 additions & 0 deletions api/oss/src/core/gateway/connections/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ async def initiate_connection(
project_id=project_id,
user_id=user_id,
secret_key=env.agenta.crypt_key,
slug=connection_create.slug,
integration_key=integration_key,
)
callback_url = f"{env.agenta.api_url}{_CALLBACK_PATH}?state={state}"

Expand Down Expand Up @@ -316,6 +318,8 @@ async def refresh_connection(
project_id=project_id,
user_id=project_id, # refresh has no user_id; use project_id as entity
secret_key=env.agenta.crypt_key,
slug=conn.slug,
integration_key=conn.integration_key,
)
callback_url = f"{env.agenta.api_url}{_CALLBACK_PATH}?state={state}"

Expand Down
13 changes: 12 additions & 1 deletion api/oss/src/core/gateway/connections/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,25 @@ def make_oauth_state(
project_id: UUID,
user_id: UUID,
secret_key: str,
slug: Optional[str] = None,
integration_key: Optional[str] = None,
) -> str:
"""Generate an HMAC-signed state token to embed in OAuth callback URLs."""
"""Generate an HMAC-signed state token to embed in OAuth callback URLs.

``slug``/``integration_key`` identify the connection so the callback can tell the
opener WHICH connection finished — the playground can have several connect flows
live at once (see ConnectToolWidget). They survive round-trip in the signed payload.
"""
payload = {
"project_id": str(project_id),
"user_id": str(user_id),
"nonce": secrets.token_hex(8),
"ts": int(time.time()),
}
if slug:
payload["slug"] = slug
if integration_key:
payload["integration"] = integration_key
payload_bytes = json.dumps(payload, sort_keys=True).encode()
payload_b64 = base64.urlsafe_b64encode(payload_bytes).decode().rstrip("=")
sig = hmac.new(
Expand Down
60 changes: 60 additions & 0 deletions api/oss/tests/pytest/unit/tools/test_oauth_state_identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from __future__ import annotations

from uuid import uuid4

from oss.src.core.gateway.connections.utils import (
decode_oauth_state,
make_oauth_state,
)

_SECRET = "unit-test-secret"


def test_state_round_trips_connection_identity():
"""slug/integration survive the signed round-trip so the OAuth callback can tag the
completion message and the playground can settle the RIGHT connect widget."""
project_id, user_id = uuid4(), uuid4()

state = make_oauth_state(
project_id=project_id,
user_id=user_id,
secret_key=_SECRET,
slug="github-main",
integration_key="github",
)
payload = decode_oauth_state(state, secret_key=_SECRET)

assert payload is not None
assert payload["project_id"] == str(project_id)
assert payload["slug"] == "github-main"
assert payload["integration"] == "github"


def test_state_omits_identity_when_not_provided():
"""Back-compat: an untagged token carries no identity keys (older callers/openers)."""
payload = decode_oauth_state(
make_oauth_state(
project_id=uuid4(),
user_id=uuid4(),
secret_key=_SECRET,
),
secret_key=_SECRET,
)

assert payload is not None
assert "slug" not in payload
assert "integration" not in payload


def test_tampered_identity_token_is_rejected():
"""The identity is inside the signed payload — flipping a byte fails the HMAC check."""
state = make_oauth_state(
project_id=uuid4(),
user_id=uuid4(),
secret_key=_SECRET,
slug="slack",
integration_key="slack",
)
tampered = state[:-1] + ("0" if state[-1] != "0" else "1")

assert decode_oauth_state(tampered, secret_key=_SECRET) is None
48 changes: 26 additions & 22 deletions services/runner/src/engines/sandbox_agent/client-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,10 @@ interface PauseLike {
export interface BuildClientToolRelayInput {
responder: Responder;
run: EmitRun;
/** One pause per turn: only the first pending gate emits its interaction and pauses. */
latch: LatchLike;
/** The approval-dialog latch. Client tools do NOT gate on it — each pending client tool parks
* its own widget, and only the approval path (`acp-interactions.ts`) enforces one dialog per
* turn. Kept in the shared input so the wiring stays symmetric; ignored here. */
latch?: LatchLike;
/** The turn-ender: `pause()` cancels the prompt; `markPausedToolCall` suppresses late frames. */
pause: PauseLike;
/** Seeds the durable interactions plane for the pending call (fire-and-forget). */
Expand All @@ -189,14 +191,13 @@ export interface BuildClientToolRelayInput {
/**
* Build the `ClientToolRelay` both delivery paths use. `onClientTool` asks the responder
* (consuming a stored browser output when one exists) and, on `pendingApproval`, emits the
* `client_tool` interaction under the latch; `onPause` ends the turn. The consumer (relay loop
* or MCP handler) calls `onClientTool` then, on a `pendingApproval` outcome, `onPause` — exactly
* the previous inline engine behavior, so Pi is unchanged.
* `client_tool` interaction (a widget) for EACH pending call so several connections requested in
* one turn all render; `onPause` ends the turn once (idempotent). The consumer (relay loop or MCP
* handler) calls `onClientTool` then, on a `pendingApproval` outcome, `onPause`.
*/
export function buildClientToolRelay({
responder,
run,
latch,
pause,
recordPendingInteraction,
toolCallIndex,
Expand Down Expand Up @@ -234,22 +235,25 @@ export function buildClientToolRelay({
const correlatedId =
toolCallIndex?.lookup(request.toolName, request.input) ??
request.toolCallId;
if (latch.tryAcquire()) {
pause.markPausedToolCall(correlatedId);
emitClientToolInteraction(run, {
id: request.id,
toolCallId: correlatedId,
toolName: request.toolName,
input: request.input,
render: request.spec.render,
});
recordPendingInteraction(
request.id,
request.toolName,
request.input,
"client_tool",
);
}
// Every pending client tool parks its OWN widget. Unlike an approval DIALOG (one at a time,
// gated by the latch), browser-fulfilled client tools are independent surfaces — an agent may
// request several connections in one turn, and each must render. The turn still ends exactly
// once: `pause.pause()` (via `onPause`) is idempotent, so N emits pause the turn once, and
// `markPausedToolCall` keeps each parked call from being force-settled as a deferred sibling.
pause.markPausedToolCall(correlatedId);
emitClientToolInteraction(run, {
id: request.id,
toolCallId: correlatedId,
toolName: request.toolName,
input: request.input,
render: request.spec.render,
});
recordPendingInteraction(
request.id,
request.toolName,
request.input,
"client_tool",
);
return "pendingApproval";
},
onPause: () => pause.pause(),
Expand Down
Loading
Loading