Skip to content

[feat] Deliver agent credentials through Daytona Secrets - #5670

Merged
mmabrouk merged 13 commits into
release/v0.108.1from
feat/daytona-secrets-v2
Aug 3, 2026
Merged

[feat] Deliver agent credentials through Daytona Secrets#5670
mmabrouk merged 13 commits into
release/v0.108.1from
feat/daytona-secrets-v2

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Aug 2, 2026

Copy link
Copy Markdown
Member

Context

Daytona agent runs need model API keys and authenticated MCP headers, but today the runner sends those values as plaintext sandbox environment variables, readable by the sandbox control plane, the agent process, and the shell. PR #5277 fixed this but was written in July against the now-dead big-agents base; main has since taken about 1,200 commits through the same files (typed run failures, capability catalog, current-turn delivery, Pi built-ins rework, session-storage rework, @daytona/sdk 0.198). This PR is a recut of #5277 onto current main, one commit, targeting main directly. Supersedes #5277.

Changes

Behind the flag AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local, the runner creates per-sandbox Daytona Secret records and puts only Secret placeholders in the sandbox create request. The SDK resolves managed model and HTTP MCP credentials into typed wire descriptors and fails closed when resolution is incomplete; only explicitly self-managed credentials remain runtime-provided.

Before (create request):

envVars: { ANTHROPIC_API_KEY: "sk-ant-..." }

After (flag on):

envVars: { ... no credential ... }, secrets: { ANTHROPIC_API_KEY: "agenta_<hex>_0" }

The sandbox process sees only the dtn_secret_... placeholder; Daytona substitutes the real value proxy-side at egress to the allowed host. The runner deletes the sandbox before its Secrets, compensates partial creation failures, and rebuilds the sandbox when the credential fingerprint rotates. With the flag on, the fingerprint wrapper applies to every Daytona run (even runs with zero opaque credentials), so a parked sandbox cannot reconnect across a local_use credential rotation. With the flag off, behavior matches main: plaintext delivery, no wrapper, no new failure modes.

On the wire, the retired flat fields (provider, deployment, endpoint, credentialMode, secrets) are replaced by a typed modelConnection object, and MCP credentials ride inside mcpServers[].connection.credentials. The runner rejects the retired fields unconditionally. Redaction now seeds from the typed shapes on every sink, including exception stacks before they reach stderr, and the SDK scopes its redactor per run instead of accumulating deny-sets across runs in one task.

Ownership is process-local by design: a hard runner crash can orphan a Daytona Secret until Daytona's auto-delete backstop. Durable managed-resource reconciliation stays a follow-up (see #5278), same as the original PR.

Tests / notes

  • Runner: tsc --noEmit clean; unit suite 94 files, 1,464 tests green; integration smoke 8 green.
  • SDK: full unit suite 1,891 passed (10 pre-existing xfails from litellm model-list lag); ruff clean.
  • Two independent review passes (Codex, gpt-5.6-sol, high reasoning) over the port and the final diff; all findings fixed, including three found in the second pass (zero-candidate fingerprint gap, unredacted stderr stacks, SDK redactor scoping).
  • Live QA against real Daytona (eu target): created a Secret with a dummy value, got a dtn_secret_ placeholder, created a sandbox whose create request carried only the Secret name, printenv inside the sandbox showed only the placeholder, then deleted sandbox before Secret and verified both gone. Nothing left behind.
  • Deploy note: the dev box's dedicated runner key (AGENTA_RUNNER_DAYTONA_API_KEY) is denied on the Secrets API; the org's main key works. Grant the runner key the Secrets permission (or reissue it) before enabling the flag there.
  • The feature is off by default.
  • The flag was renamed to AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS during review, to match every other runner Daytona setting, and is now plumbed through docker-compose, the env examples, and the Helm chart. It previously reached the runner through none of those, so it could not be enabled on a real deployment at all.
  • An under-permissioned Daytona key now fails with a message naming the variable and the permission, instead of a bare Forbidden from the provider.

What to QA

  • Enable AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local on a stack whose Daytona key has the Secrets permission, run Claude on Daytona with a managed Anthropic connection. The run completes; the sandbox env shows a dtn_secret_ placeholder, not the key.
  • Rotate the connection's credential and run again. The runner rebuilds the sandbox and uses the new value.
  • Run with an authenticated HTTP MCP server. The header value never appears in the sandbox create env.
  • Regression: with the flag unset, run Claude on Daytona and locally. Credential delivery behaves exactly as on main today.

Recut of #5277 onto current main. Behind AGENTA_DAYTONA_OPAQUE_SECRETS=process_local,
the runner creates per-sandbox Daytona Secret records and sends only Secret
placeholders in the sandbox create request instead of plaintext credential env vars.
The SDK resolves managed model and HTTP MCP credentials into typed wire descriptors
and fails closed when resolution is incomplete. Flag off, behavior matches main.

Re-expressed over main's later reworks: typed run failures, model capability
catalog, current-turn/attachment delivery, Pi built-ins rework, session-storage
rework, mount-credential tracking, @daytona/sdk 0.198.
@dosubot dosubot Bot added the size:XL This PR changes 500-999 lines, ignoring generated files. label Aug 2, 2026
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Ready Ready Preview Aug 3, 2026 7:08pm

Request Review

@dosubot dosubot Bot added Backend Feature Request New feature or request python Pull requests that update Python code labels Aug 2, 2026
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added structured model connections with provider, deployment, endpoint, environment, and credential details.
    • Added custom provider endpoints and provider-qualified Pi model selection.
    • Added typed credentials for HTTP MCP servers.
    • Added secure credential delivery for supported sandbox runs, with optional opaque secret handling.
  • Bug Fixes

    • Connection failures now surface instead of silently falling back.
    • Improved validation for credentials, endpoints, and MCP headers.
    • Expanded redaction across results, errors, logs, and streamed output.
    • Credential changes now trigger sandbox refreshes when required.

Walkthrough

This change replaces legacy model and secret fields with typed model and MCP credentials. It adds fail-closed resolution, run-scoped redaction, Daytona Secret delivery, provider overrides, and credential-rotation handling for resumed sessions.

Changes

Credential routing and runtime delivery

Layer / File(s) Summary
Typed connection and wire contracts
sdks/python/agenta/sdk/agents/connections/*, sdks/python/agenta/sdk/agents/mcp/*, services/runner/src/protocol.ts, sdks/python/agenta/sdk/agents/wire_models.py
Resolved connections and MCP servers use typed credential bindings. Run requests use nested modelConnection data.
Resolution, SDK wiring, and redaction
sdks/python/agenta/sdk/agents/dtos.py, sdks/python/agenta/sdk/agents/handler.py, sdks/python/agenta/sdk/agents/utils/wire.py, services/runner/src/redaction.ts
Connection failures propagate. Session setup materializes local environment values. Execution and wire responses use run-scoped redaction.
Run planning and Daytona delivery
services/runner/src/engines/sandbox_agent/run-plan.ts, daytona-secret-plan.ts, daytona-secret-provider.ts, daytona-secrets.ts, provider.ts
Run planning validates credentials and creates optional Daytona Secret plans. Daytona manages allocation, materialization, reconnect, pause, and cleanup.
Provider overrides and session identity
services/runner/src/extensions/*, services/runner/src/engines/sandbox_agent/pi-assets.ts, session-identity.ts, server.ts
Pi provider overrides use validated HTTPS configuration. Resumed sessions detect credential changes and can require cold acquisition.

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

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.01% which is insufficient. The required threshold is 60.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly identifies the primary change: delivering agent credentials through Daytona Secrets.
Description check ✅ Passed The description directly explains the Daytona Secrets feature, credential delivery changes, configuration, testing, and deployment requirements.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/daytona-secrets-v2

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.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Railway Preview Environment

Preview URL https://gateway-pr-5670.up.railway.app/w
Project agenta-oss-clone-spike
Image tag pr-5670-b7afa7c
Status Deployed
Railway logs Open logs
Workflow logs View workflow run
Updated at 2026-08-03T19:12:02.777Z

@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: 6

Caution

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

⚠️ Outside diff range comments (3)
services/runner/tests/unit/wire-contract.test.ts (1)

34-58: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep connection in KNOWN_REQUEST_KEYS.

protocol.ts still declares connection?: { mode: string; slug?: string }, and the runner still reads request.connection for the Pi custom-provider path. The TS runtime check rejects any request top-level key not in KNOWN_REQUEST_KEYS, so /run requests carrying connection would fail the wire-contract test.

🐛 Proposed fix
   "modelCapabilities",
+  "connection",
   "modelConnection",

Source: Coding guidelines

sdks/python/agenta/sdk/agents/utils/wire.py (1)

83-94: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Remove the remaining secrets= caller for request_to_wire.

./sdks/python/agenta/sdk/agents/integration/agents/_fake_runner_backend.py:76 passes secrets=self._secrets, but request_to_wire no longer accepts that keyword and will raise TypeError.

services/runner/src/server.ts (1)

1406-1413: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

One-shot dev path can still log an unredacted credential-bearing stack.

This catch handles an error thrown by the one-shot JSON path (Lines 1396-1400), where run(request) is awaited without a local try/catch. Because request is declared with let request: AgentRunRequest; inside the nested try block (Lines ~1372-1400), it is out of scope here, so this console.error cannot apply the same seedForRun(request).redactString(...) protection just added at Lines 1198-1204 for the streaming path. An error whose message or stack echoes a credential value (an auth failure that repeats the key, a dumped env) reaches stderr unredacted through this path.

Hoist the request declaration so it stays in scope for this catch, and redact the stack the same way the streaming path now does.

🔒 Proposed fix
         inFlight += 1;
+        let request: AgentRunRequest = {};
         try {
           const raw = await readBody(req);
-          let request: AgentRunRequest;
           try {
             request = raw.trim() ? (JSON.parse(raw) as AgentRunRequest) : {};
           } catch (err) {
             return send(res, 400, {
               ok: false,
               error: `Invalid JSON: ${err instanceof Error ? err.message : String(err)}`,
             });
           }
           ...
         } finally {
           inFlight -= 1;
         }
       ...
     } catch (err) {
       const message = err instanceof Error ? err.message : "Internal error";
-      console.error(err instanceof Error ? (err.stack ?? err.message) : err);
+      console.error(
+        err instanceof Error && err.stack
+          ? seedForRun(request).redactString(err.stack, "stderr")
+          : err,
+      );
       return send(res, 500, { ok: false, error: message });
     }
🧹 Nitpick comments (11)
sdks/python/agenta/sdk/agents/handler.py (1)

348-350: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extract a shared event-redaction helper.

agent_event_stream and agent_batch both build {"type": event.type, "data": event.data} and redact it with the same sink="agent_event" call. Extract a small helper to keep the two call sites in sync if the redaction call shape changes later.

♻️ Proposed helper extraction
+def _redact_event(event) -> Dict[str, Any]:
+    return get_active_redactor().redact_json(
+        {"type": event.type, "data": event.data}, sink="agent_event"
+    )
+
+
 async def agent_event_stream(
     harness, session_config, msgs, *, record_usage: RecordUsageFn = ambient_record_usage
 ):
     ...
             if event.type == "done":
                 event_stop_reason = (event.data or {}).get("stopReason")
-            yield get_active_redactor().redact_json(
-                {"type": event.type, "data": event.data}, sink="agent_event"
-            )
+            yield _redact_event(event)
         async for event in run:
-            events.append(
-                get_active_redactor().redact_json(
-                    {"type": event.type, "data": event.data}, sink="agent_event"
-                )
-            )
+            events.append(_redact_event(event))

Also applies to: 387-391

sdks/python/agenta/sdk/agents/wire_models.py (1)

79-104: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider sharing a base type for model and MCP credential shapes.

WireCredentialBinding/WireCredential and WireMcpCredentialBinding/WireMcpCredential repeat the same {binding: {kind, name}, value, usage} shape, differing only in the kind/usage literals. A shared generic base would remove the duplication if a third credential domain is added later.

Also applies to: 317-345

sdks/python/agenta/sdk/agents/dtos.py (1)

841-857: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move wire_model_connection below the field declarations.

PiAgentTemplate declares tool_specs, system, and append_system after this method. Pydantic still collects the fields, so behavior is unchanged. The base class and the sibling wire_tools / wire_prompt methods keep fields first, then methods. Match that order for readability.

sdks/python/oss/tests/pytest/unit/agents/connections/test_dtos_model_ref.py (1)

62-78: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Parametrize the model shapes.

The for loop hides which model shape failed. A failure reports one assertion without the input. pytest.mark.parametrize names each case in the report.

♻️ Proposed refactor
-def test_wire_model_connection_empty_before_resolution():
-    for model in (
-        "openai-codex/gpt-5.5",
-        {"provider": "openai", "model": "gpt-5.5"},
-        {
-            "provider": "openai",
-            "model": "gpt-5.5",
-            "connection": {"mode": "agenta", "slug": "openai-prod"},
-        },
-        {
-            "provider": "openai",
-            "model": "gpt-5.5",
-            "connection": {"mode": "self_managed"},
-        },
-    ):
-        config = PiAgentTemplate(model=model)
-        assert config.wire_model_connection() == {}
+@pytest.mark.parametrize(
+    "model",
+    [
+        "openai-codex/gpt-5.5",
+        {"provider": "openai", "model": "gpt-5.5"},
+        {
+            "provider": "openai",
+            "model": "gpt-5.5",
+            "connection": {"mode": "agenta", "slug": "openai-prod"},
+        },
+        {
+            "provider": "openai",
+            "model": "gpt-5.5",
+            "connection": {"mode": "self_managed"},
+        },
+    ],
+)
+def test_wire_model_connection_empty_before_resolution(model):
+    config = PiAgentTemplate(model=model)
+    assert config.wire_model_connection() == {}

Confirm that pytest is imported in this module before you apply the change.

sdks/python/oss/tests/pytest/unit/agents/connections/test_resolver.py (1)

22-23: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Share _credential_environment between the two test modules.

sdks/python/oss/tests/pytest/unit/agents/platform/test_connections_http.py defines the same helper. Move one copy into a shared conftest.py or a test-utility module, then import it in both places. Add a parameter type hint at the same time.

services/runner/src/engines/sandbox_agent/provider.ts (2)

95-115: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use a codepoint sort in canonicalJson.

localeCompare is locale- and ICU-sensitive. The fingerprint is compared only inside one process today, so behavior is correct now. A plain codepoint comparison removes the dependency on the runtime locale and makes the digest reproducible if the fingerprint is ever persisted or compared across processes.

♻️ Proposed change
-    .sort(([left], [right]) => left.localeCompare(right));
+    .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));

155-173: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reuse buildDaytonaCreate's attachment handling instead of repeating it.

buildDaytonaCreate already adds secrets when secretAttachments is non-empty (Lines 83-85). buildDaytona repeats that logic on top of createFields. The duplicated rule can diverge. Consider passing the attachments into buildDaytonaCreate inside buildDaytona, or dropping the now-unused parameter from buildDaytonaCreate.

services/runner/src/engines/sandbox_agent/run-plan.ts (1)

470-495: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider building the secret plan after the MCP capability gates.

buildDaytonaSecretPlan validates request.mcpServers. The Pi user-MCP rejection (Line 544) and the reserved-server-name rejection (Line 553) run later. On a flag-on Pi + Daytona run that declares an MCP server with an invalid URL, the caller now receives a secret-plan message instead of PI_USER_MCP_UNSUPPORTED_MESSAGE. Moving the plan construction below those gates keeps the more actionable message. No credential is allocated at plan time, so this is message quality only.

services/runner/src/engines/sandbox_agent/daytona-secret-provider.ts (1)

21-28: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Remove the unused plan field from RegistryEntry.

plansMatch compares only createFingerprint. entry.plan is written at Line 202 and never read. It holds plaintext credential values in memory for the lifetime of the entry. Dropping it reduces the in-memory credential surface.

Also applies to: 48-50

services/runner/src/engines/sandbox_agent/environment.ts (1)

618-626: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Use an options object for buildSandboxProvider.

The call order matches the current signature, but two adjacent optional object parameters still allow a silent mismatch in this package. Convert buildSandboxProvider to a single options object, following the buildDaemonEnv pattern.

services/runner/src/extensions/model-provider-override.ts (1)

11-49: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Reject non-public baseUrl hosts in validatePiModelProviderOverride.

validatePiModelProviderOverride accepts custom HTTPS addresses without host checks, so values like https://127.0.0.1/ or https://169.254.169.254/ pass and are written into AGENTA_AGENT_MODEL_PROVIDER_OVERRIDE. The Pi extension then registers the built-in provider with that baseUrl, which can route model requests through a private host. Add synchronous host checks here, following the same SSRF protections already used for MCP URLs, such as rejecting localhost and blocked IP literals.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b4248030-8db8-4b58-8634-37ae336b178b

📥 Commits

Reviewing files that changed from the base of the PR and between 99ab003 and 3f7a6b1.

📒 Files selected for processing (73)
  • sdks/python/agenta/sdk/agents/adapters/harnesses.py
  • sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py
  • sdks/python/agenta/sdk/agents/capabilities.py
  • sdks/python/agenta/sdk/agents/connections/__init__.py
  • sdks/python/agenta/sdk/agents/connections/endpoints.py
  • sdks/python/agenta/sdk/agents/connections/errors.py
  • sdks/python/agenta/sdk/agents/connections/models.py
  • sdks/python/agenta/sdk/agents/connections/resolver.py
  • sdks/python/agenta/sdk/agents/dtos.py
  • sdks/python/agenta/sdk/agents/handler.py
  • sdks/python/agenta/sdk/agents/interfaces.py
  • sdks/python/agenta/sdk/agents/mcp/__init__.py
  • sdks/python/agenta/sdk/agents/mcp/models.py
  • sdks/python/agenta/sdk/agents/mcp/resolver.py
  • sdks/python/agenta/sdk/agents/platform/connections.py
  • sdks/python/agenta/sdk/agents/utils/wire.py
  • sdks/python/agenta/sdk/agents/wire_models.py
  • sdks/python/agenta/sdk/redaction/context.py
  • sdks/python/agenta/sdk/redaction/seed.py
  • sdks/python/oss/tests/pytest/unit/agents/connections/test_dtos_model_ref.py
  • sdks/python/oss/tests/pytest/unit/agents/connections/test_models.py
  • sdks/python/oss/tests/pytest/unit/agents/connections/test_resolver.py
  • sdks/python/oss/tests/pytest/unit/agents/golden/run_request.attachment.json
  • sdks/python/oss/tests/pytest/unit/agents/golden/run_request.claude.json
  • sdks/python/oss/tests/pytest/unit/agents/golden/run_request.pi_core.json
  • sdks/python/oss/tests/pytest/unit/agents/mcp/test_resolver.py
  • sdks/python/oss/tests/pytest/unit/agents/platform/test_connections_http.py
  • sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py
  • sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py
  • sdks/python/oss/tests/pytest/unit/agents/test_redaction_scope.py
  • sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py
  • sdks/python/oss/tests/pytest/unit/agents/test_wire_models.py
  • services/runner/src/engines/sandbox_agent/daemon.ts
  • services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts
  • services/runner/src/engines/sandbox_agent/daytona-secret-provider.ts
  • services/runner/src/engines/sandbox_agent/daytona-secrets.ts
  • services/runner/src/engines/sandbox_agent/daytona.ts
  • services/runner/src/engines/sandbox_agent/environment-setup.ts
  • services/runner/src/engines/sandbox_agent/environment.ts
  • services/runner/src/engines/sandbox_agent/errors.ts
  • services/runner/src/engines/sandbox_agent/mcp.ts
  • services/runner/src/engines/sandbox_agent/pi-assets.ts
  • services/runner/src/engines/sandbox_agent/pi-model-config.ts
  • services/runner/src/engines/sandbox_agent/provider.ts
  • services/runner/src/engines/sandbox_agent/run-plan.ts
  • services/runner/src/engines/sandbox_agent/run-turn.ts
  • services/runner/src/engines/sandbox_agent/runtime-policy.ts
  • services/runner/src/engines/sandbox_agent/session-identity.ts
  • services/runner/src/extensions/agenta.ts
  • services/runner/src/extensions/model-provider-override.ts
  • services/runner/src/protocol.ts
  • services/runner/src/redaction.ts
  • services/runner/src/server.ts
  • services/runner/tests/setup/hermetic-env.ts
  • services/runner/tests/unit/daytona-secret-plan.test.ts
  • services/runner/tests/unit/daytona-secret-provider.test.ts
  • services/runner/tests/unit/daytona-secrets.test.ts
  • services/runner/tests/unit/extension-tools.test.ts
  • services/runner/tests/unit/mcp-servers.test.ts
  • services/runner/tests/unit/redaction-sinks.test.ts
  • services/runner/tests/unit/sandbox-agent-daemon.test.ts
  • services/runner/tests/unit/sandbox-agent-orchestration.test.ts
  • services/runner/tests/unit/sandbox-agent-pi-assets.test.ts
  • services/runner/tests/unit/sandbox-agent-pi-model-config.test.ts
  • services/runner/tests/unit/sandbox-agent-provider.test.ts
  • services/runner/tests/unit/sandbox-agent-run-plan.test.ts
  • services/runner/tests/unit/server.test.ts
  • services/runner/tests/unit/session-keepalive-approval.test.ts
  • services/runner/tests/unit/session-keepalive-dispatch.test.ts
  • services/runner/tests/unit/session-mcp-layering.test.ts
  • services/runner/tests/unit/session-pool.test.ts
  • services/runner/tests/unit/wire-contract.test.ts
  • services/runner/tests/utils/qa-transcripts.ts
💤 Files with no reviewable changes (1)
  • sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py

Comment thread sdks/python/agenta/sdk/agents/connections/endpoints.py
Comment thread services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts
Comment thread services/runner/src/engines/sandbox_agent/daytona-secrets.ts Outdated
Comment thread services/runner/src/engines/sandbox_agent/session-identity.ts
Comment thread services/runner/tests/unit/sandbox-agent-orchestration.test.ts Outdated
- SDK: build_resolved_connection wraps classify_environment failures in
  InvalidConnectionConfigurationError (422), matching the effective_endpoint
  handling; malformed bindings no longer surface as 500s. Test added.
- runner: buildDaytonaSecretPlan rejects an opaque model credential whose
  binding name (case-insensitive) collides with a direct environment binding,
  so one name can never ride both envVars and secrets in a create request.
  local_use bindings now settle before opaque ones so wire order cannot evade
  the check. Tests added.
- runner: extracted one shared isDaytonaNotFound helper (daytona-secrets.ts)
  recognizing both the typed DaytonaNotFoundError and 404-shaped errors; used
  by Secret cleanup and the process-local sandbox lifecycle wrapper. Test added.
- hermetic-env AGENTA_DAYTONA_OPAQUE_SECRETS scrub: already present at HEAD
  (SCRUBBED list + per-test re-scrub); no change needed.
- daytona-secret-provider proxy staleness claim: rebutted, intentionally
  unchanged (all lifecycle methods are declared on the facade; attachments
  only affect create, which always rebuilds the delegate).
- configFingerprint environment-values claim: rebutted, intentionally
  unchanged (modelConnection.environment carries non-secret config only by
  contract; secret material rides typed credentials whose values are already
  stripped from the fingerprint).

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This review is a guide for reading the diff, plus inline notes at the places where a design choice or a tradeoff is easy to miss.

Suggested reading order

  1. services/runner/src/protocol.ts: the wire shapes. Model routing and credentials now arrive grouped under modelConnection, and MCP secret headers ride typed connection.credentials, replacing the old flat secrets map.
  2. sdks/python/agenta/sdk/agents/connections/models.py and the new endpoints.py: how the SDK types each credential and splits secret values from plain environment values before anything goes on the wire.
  3. services/runner/src/engines/sandbox_agent/run-plan.ts: the feature flag gate and how the per-run secret plan is built.
  4. daytona-secret-plan.ts, then daytona-secrets.ts, then daytona-secret-provider.ts: the Daytona Secret lifecycle, from deciding what becomes a Secret to creating and cleaning them up.
  5. provider.ts: where the Secret wrapper hooks into sandbox provisioning.
  6. session-identity.ts: how reuse decisions changed (a shape-only fingerprint for "same setup" and a value hash for "did a credential rotate").
  7. src/redaction.ts: how credential values are kept out of records, spans, and logs.

One-line roles

  • daytona-secret-plan.ts: pure decision logic that splits a request's values into Daytona Secrets versus plain environment; no I/O.
  • daytona-secrets.ts: a thin client for Daytona's Secret API with idempotent deletes and shared not-found handling.
  • daytona-secret-provider.ts: wraps the sandbox provider so Secrets exist before the sandbox that uses them and are removed only after it is gone.
  • extensions/model-provider-override.ts: shared validation for the public routing override (provider id and base URL, no secret material) that the runner hands to the in-Pi extension.
  • connections/endpoints.py (SDK): resolves a connection into typed credentials plus a small allowlist of plain environment names, and computes the effective endpoint.

Verification already done

The branch went through two full review passes (an architecture and security pass, then a line-level pass on the recut), and live QA against a real Daytona deployment covering create, reconnect after park, credential rotation, and teardown. With the feature flag off, no secret plan is built and no wrapper is applied, so runner behavior is identical to main.

// Keep this runner-side boundary aligned with the resolver-owned contract in
// sdks/python/agenta/sdk/agents/connections/endpoints.py. Environment is public config;
// every other provider value must arrive as a typed credential.
const PUBLIC_MODEL_ENVIRONMENT_BINDINGS = new Set([

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This module only decides what becomes a Daytona Secret. It performs no I/O: no network calls, no Daytona API use, nothing outside pure validation and classification. The actual Secret creation lives in daytona-secrets.ts. Note the allowlist rule here: only these four non-secret names (AWS/GCP region and project settings) may travel as plain environment values. Every other provider value must arrive as a typed credential, and assertPublicEnvironmentBinding hard-fails anything else.

// Two passes: settle every direct (local_use) binding first, so the collision check in
// `add` sees the complete direct set regardless of credential order on the wire.
for (const credential of connection.credentials ?? []) {
if (credential.usage !== "local_use") continue;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

CAVEAT, and a deliberate one: AWS and GCP "local_use" credentials stay as plaintext environment values inside the sandbox instead of becoming Daytona Secrets. The provider SDKs read several environment variables natively and compute request signatures from them, so a Daytona placeholder value would break signing. This is a documented exception with its own tight allowlist (LOCAL_USE_MODEL_CREDENTIAL_BINDINGS above), not an oversight. No opaque provider key is allowed to take this path.

);
}

async function deleteIdempotently(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This file is a thin CRUD wrapper around Daytona's Secret API. Deletes are idempotent on purpose: teardown paths can retry safely because deleting an already-deleted Secret is treated as success. The shared isDaytonaNotFound predicate just above covers both error shapes Daytona can produce, the typed DaytonaNotFoundError and a plain object with statusCode === 404.

const facade: ProcessLocalDaytonaSecretProvider = {
name: "daytona",
async create(...args: unknown[]): Promise<string> {
const allocation = await allocateDaytonaSecrets(plan, api);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The lifecycle rule this wrapper enforces: Secrets are created before the sandbox that references them, and the sandbox is destroyed before its Secrets are deleted. That ordering means no live sandbox ever points at a deleted Secret. When create fails partway, the code compensates: if the provider was never built, the freshly allocated Secrets are deleted; if the remote create may have partially happened, Secrets are retained on purpose, because deleting them could strand a half-created sandbox.

* Wrap Daytona provisioning with process-local Secret allocation.
*
* A parked sandbox and its allocation live in the registry together. Secret ownership is
* process-local BY DESIGN: the registry dies with the runner process, so a hard crash can

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

TRADEOFF, accepted deliberately: Secret ownership lives only in this process's memory. If the runner process crashes hard, the registry is gone and a Daytona Secret can be orphaned until Daytona's own auto-delete backstop reaps the sandbox. Durable reconciliation (recording ownership somewhere that survives a crash) is the explicit follow-up design in PR #5278, so reviewers should not expect recovery machinery here.

connection: request.connection ?? null,
deployment: request.deployment ?? null,
endpoint: request.endpoint ?? null,
modelConnection: request.modelConnection

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is the split between the two hashes. configFingerprint is shape-only: credentials are stripped down to {binding, usage}, so no secret value ever enters it, and it answers "is this the same setup as the parked session". computeCredentialEpoch below is the one that hashes the actual values and answers "did a credential rotate". When the epoch changes, the parked sandbox is evicted and rebuilt instead of being reused with stale credentials.

* they still transit runner memory and can be echoed by the model), so the deny-set seeds from
* the request, not from the delivered environment.
*/
export function requestSecretValues(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Every sink now seeds its deny-set from the typed wire shapes: durable records, spans, and (new in this PR) stderr exception stacks. requestSecretValues collects the model connection's credential values and environment values plus every MCP credential value, which is a superset of what actually lands in the sandbox environment. So even if a model echoes a credential back, or an error message includes one, the value is scrubbed before it reaches storage or logs.

return resolved


def classify_environment(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is where the SDK splits resolved values into two disjoint groups: plain environment (only the four allowlisted non-secret names, like AWS_REGION) and typed credentials (everything else). The runner enforces the same boundary on its side, so a secret value cannot slip through as environment. Managed resolution also fails closed: if a value is malformed, build_resolved_connection raises an error that maps to a 422 response instead of silently degrading the run to runtime-provided credentials.

name: str = Field(min_length=1)


class ResolvedMCPCredential(BaseModel):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

MCP header credentials are now typed instead of riding the plain headers dict. ResolvedMCPCredential masks its value on any Pydantic dump, so a log line or a serialized model can never carry the real secret; the value only surfaces through to_wire() at the materialization boundary. On a Daytona run there is one more layer: the value is swapped for a Daytona Secret placeholder before the sandbox create request, so plaintext never reaches the create call at all.

* URL must pass the SSRF guard. Called early in acquire — BEFORE any sandbox or Daytona Secret
* is created — and again per server at ACP materialization (`toAcpMcpServers`).
*/
export async function validateUserMcpServers(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

DECISION: this keeps main's SSRF rule (validateUserMcpUrl) rather than the stricter rule from the earlier PR. The operator-controlled allowlist may exempt a non-https host, which matters for internal MCP servers, and https is required for everything not on that allowlist. One small deliberate deviation from flag-off main: this structural validation now runs early in acquire, before any sandbox or Daytona Secret is created, so a bad MCP config fails the run before it has side effects.

Re-express the typed-wire delta on top of the Codex harness feature:
- keep wire_harness_mode / harnessMode alongside modelConnection; drop the
  retired flat fields main's copies still carried (provider, secrets,
  credentialMode) from protocol.ts, wire_models.py, and the fingerprint
- port main's new Codex tests (run-plan gates, wire golden) to the typed
  modelConnection shape and regenerate run_request.codex.json
- runner: tsc clean, 99 files / 1534 unit tests green; SDK: 1920 passed

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reviewed, please address comments. Answer each comment and suggest how we can fix it. For large item let's have the discussion in chat. For small item just go ahead and fix and commit

Gaps:

  • Docs are not being updated. They should. Same goes for readme mentioning security.
  • Custom secrets are not being taken into account. Maybe the wiring is not done (we are not sending them at all). In any case we need a plan on how to take them into account in addition to MCPs

Comment thread services/runner/src/protocol.ts
Comment thread services/runner/src/protocol.ts
Comment thread services/runner/src/protocol.ts
Comment thread services/runner/src/protocol.ts
Comment thread services/runner/src/protocol.ts
Comment thread services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts
Comment thread services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts
}

/** Opaque comparison key for every field baked into a parked Daytona sandbox at create time. */
export function daytonaCreateFingerprint(input: {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

does this mean that changing secrets break the finger print for daytona and moves things automatically into cold 2 mode (resending the conversation from scratch)? i

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, with one clarification about which check does it.

This fingerprint is narrower than the session pool's. It covers only the fields baked into a Daytona sandbox at create time, and it guards reconnecting to a still-live sandbox: if the secret plan differs, the sandbox cannot serve this request, so the wrapper cleans up its Secret records and forces a fresh create.

It is not, however, what puts you in cold-2. By the time this runs, the pool has already made that decision: computeCredentialEpoch in session-identity.ts hashes the credential values, server.ts sees credentials-rotated, and the warm session is evicted before reconnect is ever attempted. So this fingerprint is a second, narrower guard rather than the cause. Changing a key does force a conversation restart today, and it did on main too, for that reason.

The improvement you are describing is real and this feature is what unlocks it. With the flag on, the sandbox holds a dtn_secret_<id> placeholder, not the key. So a value rotation does not need a new sandbox at all: update the Daytona Secret record behind the placeholder and the running sandbox keeps working. Only a change to the plan's shape (a different binding, a different allowed host, a credential added or removed) genuinely requires a new sandbox, because that is what is actually baked in.

Concretely that means splitting this fingerprint in two, a shape part and a value part, and adding an update path so a value-only change updates the Secret records instead of tearing down. One live constraint worth naming: our runner's Daytona key currently does not have permission to manage Secrets, so this whole path needs that fixed before it can be exercised against a real account regardless.

I have put this in the chat with the broader "what should invalidate what" question, since the two are the same design problem seen from different ends.

Comment thread services/runner/src/engines/sandbox_agent/session-identity.ts
Comment thread services/runner/src/engines/sandbox_agent/session-identity.ts
Four problems, all caught by suites the port never ran locally.

1. The author's connection choice stopped reaching the runner. The port dropped
   `wire_model_ref()` because it also carried the retired flat `provider` field,
   but that method was the only emitter of the top-level `connection` reference.
   The runner still gates Pi's OpenAI-compatible models.json path on
   `request.connection.mode === "agenta"` and names the provider from its slug
   (`pi-model-config.ts`), so a named custom connection silently fell back to the
   generic provider-override env. Restored as `wire_connection_ref()`, which emits
   only `{mode, slug}` and stays empty for the project default so a plain run's
   payload is unchanged.

2. `_fake_runner_backend.py` still passed `secrets=` to `request_to_wire`, which
   the typed wire no longer accepts. It now mirrors the production sandbox-agent
   backend: the parameter is accepted for interface parity and ignored, because
   credentials ride inside `config` as `modelConnection`.

3. The services handler tests still built `ResolvedConnection(env=...)` and
   expected a resolution failure to degrade into an empty `runtime_provided`
   plan. Ported to typed credentials, and the degradation test became a
   fail-closed test. The shared no-credential stub now reads its provider from
   the harness capability table, because a fail-closed resolve must return a
   connection the post-resolve gate accepts for whichever harness is running.

4. The custom-connection replay test asserted the retired flat wire.

Also: the credential epoch now uses an HMAC keyed with a per-process random key
instead of a bare sha256 of the secret values. The epoch is only ever compared
within one process, so the key costs nothing, and the digest stops being
brute-forceable against candidate API keys. This is what CodeQL flagged.

Verified: runner tsc clean, 99 files / 1534 tests; SDK 2067 passed (unit +
integration) with the 10 pre-existing litellm xfails; services 100 passed.
Comment thread services/runner/src/engines/sandbox_agent/session-identity.ts Fixed
Review found the feature had no documentation and, more importantly, no way
for an operator to turn it on: the environment variable was read straight from
process.env in the runner and was never plumbed through docker-compose or the
Helm chart, so setting it on the host did nothing.

- Renamed the flag to AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS. Every other runner
  Daytona setting is AGENTA_RUNNER_DAYTONA_*, and the bare AGENTA_DAYTONA_ prefix
  collided visually with the DAYTONA_* variables that configure the unrelated
  code-evaluator sandbox. The feature is unreleased, so no operator has set the
  old name.
- Plumbed it through all seven docker-compose runner services, the four env
  examples, the Helm deployment template, values.yaml, and values.schema.json.
- Documented it in the configuration reference (what it does, the two caveats:
  the Daytona API key needs Secrets permission, and AWS keys cannot be hidden)
  and in the how-agents-run concept page (why an agent reading its own
  environment is the threat, and what the sandbox sees with the flag on).

Also expanded the wire-contract comments the review asked about: what the
usage values mean and why there are exactly two, what endpoint covers beyond
OpenAI-compatible routes (Azure apiVersion, AWS/Vertex region), why a Daytona
secret candidate is called a candidate, why allowedHost can never be a
wildcard, and why the retired-field guard rejects rather than ignores while
`connection` is deliberately not among the retired fields.
CodeQL reports the alert on the createHmac call itself, and a suppression
comment only applies to alerts on its own line. The preceding-comment form did
not take, so the call is split out and carries the marker inline.
Comment thread services/runner/src/engines/sandbox_agent/session-identity.ts Fixed
CodeQL flagged the credential epoch as an insecurely hashed password, and
GitHub code scanning does not honour inline suppression comments, so the check
stayed red. Rather than argue with the scanner, the design changed, and the
result is better than what it replaced.

The epoch answers one question: did the credential material change since this
session was parked? It never needed a digest to do that. It now holds the
material behind a `CredentialMaterial` value object and compares it with
`timingSafeEqual`.

Why holding the values is not a step backwards. A digest of an API key was
never much protection, because keys carry little enough entropy that a leaked
digest can be attacked offline, and the parked environment already holds the
plaintext anyway. The realistic risk is a value reaching a log line, and that
is now structurally impossible rather than a convention: the material sits in
a private field with no getter, and every route from an object to text
(`String`, template literals, `JSON.stringify`, `util.inspect`, so
`console.log`) returns a placeholder. This mirrors what the Python side
already does, where `ResolvedCredential` masks its value on dump and hides it
from `repr`.

A test pins every one of those rendering routes, so a future refactor that
drops an override fails loudly instead of leaking silently.

No behavior change: the same rotations evict and the same re-minted per-turn
bearers do not. Runner tsc clean, 99 files / 1535 tests.
The README still described `request.secrets`, which this change retired, and
said nothing about hiding keys from the sandbox. It now describes the
consumer-grouped shape, the reject-on-retired-field guard, and the Daytona
Secrets path with its three caveats.
@mmabrouk

mmabrouk commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Thanks, this was a good review. Every inline comment has a reply. Here are the two gaps from the review body, plus a note on CI.

Docs

You were right, and the gap was worse than missing prose: the feature could not be turned on at all. The environment variable was read straight from process.env in the runner and was never plumbed through docker-compose or the Helm chart, so an operator setting it on the host would have seen nothing happen. Fixed in bd0067c:

  • Plumbed through all seven docker-compose runner services, the four env examples, the Helm deployment template, values.yaml, and values.schema.json.
  • Renamed to AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS. Every other runner Daytona setting uses that prefix, and the bare AGENTA_DAYTONA_ form collided visually with the DAYTONA_* variables that configure the unrelated code-evaluator sandbox. The feature is unreleased, so nobody has set the old name.
  • Documented in the configuration reference (what it does, and the two caveats: the Daytona API key needs permission to manage Secrets, and AWS keys cannot be hidden) and in the how agents run concept page, where I explain why an agent reading its own environment is the threat rather than just listing the setting.
  • The runner README's Auth section still described request.secrets, which this change retired. Rewritten in 5189fa8.

Custom secrets

You are right that they are not handled. I traced it before answering, and the finding is that they never were, on this branch or on main.

The vault already supports a custom_secret kind and Settings has UI to create one. The only consumer is the MCP server form, where a custom secret can be attached as a secret HTTP header; that path works and this PR carries it through the new typed shape. There is no agent-template field, no wire field, and no runner handling that would put a custom secret into the sandbox environment for a skill or a bash command to read. It was a deliberate deferral: docs/design/vault-named-secrets/context.md names your exact example (GITHUB_TOKEN, STRIPE_KEY) as out of scope, and docs/design/agent-workflows/scratch/open-issues.md still tracks the consumption path as open.

So nothing regressed. What this PR does is make it much easier to add, because the consumer-grouped shape is exactly the extension point. I have put a plan in the chat. The key insight, which I got wrong at first and corrected: a custom secret is hideable too, as long as the user tells us which host it may be sent to. curl -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/... works fine against a placeholder. The unhideable cases are narrower than I first said.

Other fixes in this round

  • CodeQL is green. It read the credential epoch as an insecurely hashed password, and GitHub code scanning does not honour inline suppression comments, so I changed the design instead of arguing with it. The epoch only ever needed to answer "did the credentials change", which never required a digest. It now holds the material behind a value object compared with timingSafeEqual, with every route from object to text overridden to print a placeholder, and a test pinning each of those routes. That is stronger than the digest it replaced, since a digest of an API key can be attacked offline and a redacted field prints nothing.
  • A real regression from the merge with main, caught by CI. Dropping the retired flat fields also dropped the top-level connection reference, which is not a credential. The runner routes named OpenAI-compatible Pi runs through Pi's models.json using its slug, so those runs were silently falling back to the generic provider path. Fixed in 963f607 with a test that pins the field on the wire.
  • Three test files still used the retired shape and were ported.

On the failing CI

The remaining red checks are the acceptance suites, and they are not this PR. Every one of them fails identically: 194 to 314 assertions of Expected 200, got 401 from the admin account-creation endpoint on the PR's Railway preview. This branch changes no file under api/, so it cannot affect that endpoint's auth.

The evidence that points at the Railway work rather than at us: acceptance passed on this branch's first commit, when previews still used the legacy deploy path. Since then the repo variable switched previews to clone mode, and this PR's deploy job now runs preview-clone-create.sh --verify-only against a clone whose AGENTA_AUTH_KEY does not match AGENTA_TEST_OSS_AUTH_KEY. #5678 is the cutover for exactly that path, and its own preview passes.

Everything this PR is responsible for is green: the runner suites, both SDK suites including integration, the services suites, the API suites, lint, format, the Helm render test, and CodeQL.

…bare 403

A Daytona API key that can create sandboxes does not automatically have the
separate permission to manage Secrets. When it does not, enabling
AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS makes every run with a hideable
credential fail at sandbox creation, and the raw provider error is a bare
Forbidden that says nothing about the flag that caused it.

This is the first thing an operator hits when turning the feature on, so the
create path now recognizes a permission refusal and re-raises it as a message
naming the variable to fix, the permission to grant, and the way to revert.
The provider's own error is kept as the cause so the logs keep the detail.
Every other failure keeps its original error untouched.

The docs promote the same point from a bullet to a warning, and say plainly
that the runner does not fall back to plaintext, because doing the
unprotected thing silently would be worse than stopping.

Runner tsc clean, 99 files / 1538 tests.
mmabrouk added a commit that referenced this pull request Aug 3, 2026
`RunPlan` had grown to thirty fields on one flat interface, and every
consumer took the whole thing regardless of how little it read. Raised in
review of #5670.

The fields are grouped into `credentials`, `workspace`, `tools`, and `prompt`.
The five identity fields (`harness`, `acpAgent`, `sandboxId`, `isPi`,
`isDaytona`) stay at the top level because almost every consumer branches on
them, and `sandboxPermission` stays there too since the declared security
boundary is its own concern.

The payoff is not the tidier interface. It is that a consumer can now say what
it actually touches. `prepareWorkspace` used to take a nine-key `Pick` of the
flat plan; it now takes three named slices and the parameter type reads as a
description of the function. Several other consumers narrowed the same way,
which means a future change to, say, credential delivery has a compiler-checked
list of what depends on it.

The one rename is `plan.prompt` to `plan.prompt.text`, forced by the group
taking the name. Nothing else was renamed, no logic changed, and no `any` cast
or `@ts-expect-error` was added.

Runner tsc clean, 99 files / 1535 tests, the same counts as before the change.
…he gap

Review catch. `wire_connection_ref()` emits a top-level `connection` field for
a self-managed or named Agenta connection, and the runner reads its slug to
register a custom OpenAI-compatible Pi run in Pi's models.json. But
`WireRunRequest` no longer declared the field, so the exported contract schema
omitted something the implementation both produces and requires. A client
generated from that schema would drop it, and those runs would silently fall
back to the generic provider-override path.

The field went missing because it sat next to the flat credential fields this
change retired and got swept out with them. It is not a credential. It is the
author's connection CHOICE, which is non-secret routing config.

Restored `WireConnection` and the `connection` field on `WireRunRequest`.

Two reasons nothing caught this, both now closed:

- `KNOWN_REQUEST_KEYS` was stale in exactly the same way, and its guard is a
  subset check over three sample payloads, none of which names a connection.
  Added the key, and added a test asserting the set EQUALS the schema's
  declared aliases in both directions. A key the schema declares but the
  producer never emits is dead contract surface, so equality is the honest
  assertion.
- A validation-based test could not have caught it either: `_WireModel` sets
  `extra="allow"`, so a payload carrying a field the schema forgot still
  validates cleanly with the field quietly demoted to an extra. The new test
  is structural rather than validation-based for that reason, and the second
  new test round-trips a named connection through `model_dump` to prove it
  survives as a modelled field rather than an extra.

I verified both guards fail when the schema field is removed, so neither is a
test that can never break.

Also corrected the runner's own doc comment on the field. It claimed "the
current SDK resolver does NOT send it", which described the regression rather
than the contract, and would have led the next reader to delete the
models.json path as dead code.

Verified: SDK 2069 passed with the 10 pre-existing litellm xfails, services
100 passed, runner tsc clean and 99 files / 1538 tests, ruff clean.
`RunPlan` had grown to thirty fields on one flat interface, and every
consumer took the whole thing regardless of how little it read. Raised in
review of #5670.

The fields are grouped into `credentials`, `workspace`, `tools`, and `prompt`.
The five identity fields (`harness`, `acpAgent`, `sandboxId`, `isPi`,
`isDaytona`) stay at the top level because almost every consumer branches on
them, and `sandboxPermission` stays there too since the declared security
boundary is its own concern.

The payoff is not the tidier interface. It is that a consumer can now say what
it actually touches. `prepareWorkspace` used to take a nine-key `Pick` of the
flat plan; it now takes three named slices and the parameter type reads as a
description of the function. Several other consumers narrowed the same way,
which means a future change to, say, credential delivery has a compiler-checked
list of what depends on it.

The one rename is `plan.prompt` to `plan.prompt.text`, forced by the group
taking the name. Nothing else was renamed, no logic changed, and no `any` cast
or `@ts-expect-error` was added.

Runner tsc clean, 99 files / 1535 tests, the same counts as before the change.
@mmabrouk

mmabrouk commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Confirmed and fixed in 9023f8c. Good catch, and the reasoning about extra="allow" masking it is exactly right.

What happened. connection sat next to the flat credential fields this change retired (provider, deployment, credentialMode, endpoint) and got swept out with them. It is not a credential. It is the author's connection choice, which is non-secret routing config the runner reads directly.

WireConnection and the connection field are restored on WireRunRequest.

Why nothing caught it, and what now does. Two separate holes, both closed.

KNOWN_REQUEST_KEYS was stale in exactly the same way, and its guard is a subset check over three sample payloads, none of which names a connection. So the producer could emit a key the guard had never heard of and the assertion still passed. Added the key, and added test_known_request_keys_match_the_wire_schema, which asserts the set equals the schema's declared aliases in both directions. Equality rather than subset on purpose: a key the schema declares and the producer never emits is dead contract surface, and a reader will assume it is real.

The extra="allow" point needed a different kind of test. A payload carrying a field the schema forgot validates cleanly, with the field quietly demoted to an extra, so no validation-based test can catch this class of bug. The new guard is structural, reading WireRunRequest.model_fields rather than validating a payload. A second test round-trips a named connection through model_validate and model_dump to prove the field survives as a modelled field rather than an extra, which is the property a generated client depends on.

I checked both guards actually fail when the schema field is removed, so neither is a test that can never break.

One more thing your comment led me to. The runner's own doc comment on connection in protocol.ts claimed "the current SDK resolver does NOT send it, so the models.json path only activates for a direct caller that still supplies it". That was written during the port and described the regression rather than the contract. Left alone it would have led the next reader to delete pi-model-config.ts's models.json path as dead code. Rewritten to say what the field is for and that it is load-bearing.

Verified: SDK 2069 passed with the 10 pre-existing litellm xfails, services 100 passed, runner type check clean with 99 files and 1538 tests, ruff clean.

[refactor] Group RunPlan by concern instead of one flat bag
@mmabrouk
mmabrouk changed the base branch from main to release/v0.108.1 August 3, 2026 19:07
@mmabrouk
mmabrouk merged commit 467842f into release/v0.108.1 Aug 3, 2026
32 of 33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backend Feature Request New feature or request python Pull requests that update Python code size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants