Skip to content

[feat] Deliver agent credentials through Daytona Secrets#5242

Draft
mmabrouk wants to merge 46 commits into
big-agentsfrom
feat/daytona-secret-delivery
Draft

[feat] Deliver agent credentials through Daytona Secrets#5242
mmabrouk wants to merge 46 commits into
big-agentsfrom
feat/daytona-secret-delivery

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Jul 11, 2026

Copy link
Copy Markdown
Member

Context

Daytona Secrets can keep supported model and HTTP MCP credentials out of the sandbox environment. The current implementation also adds crash-safe cleanup, warm-session recovery, credential rotation, retries, worker claims, an API router, and a database migration.

Those are two separate architecture decisions. This draft is now the review point for recutting them into two stacked PRs. The current database-backed implementation should not merge unchanged.

Proposed stack

PR A: deliver opaque credentials through Daytona Secrets

PR A contains the functional security mechanism:

  • typed model and HTTP MCP credential bindings;
  • effective endpoints and exact-host validation;
  • Daytona Secret planning and provider operations;
  • Secret-name sandbox attachment and MCP placeholders;
  • normal teardown and failed-provisioning compensation;
  • process-local warm-session allocation records;
  • credential generation fingerprints.

PR A contains no API router, migration, shared auth-middleware change, durable janitor, claims, retry cursor, control token, or lease HTTP client.

A hard runner crash may leave an orphaned Daytona resource. The feature therefore remains disabled for production until PR B merges.

PR B: reconcile managed external resources durably

PR B adds a reviewed managed_resources control plane:

  • durable desired and observed state;
  • tenant and owner references;
  • idempotency and generation digests;
  • optimistic versions;
  • due-work queries and retry scheduling;
  • fenced worker claims;
  • child external-resource identities;
  • a controller port and dedicated reconciler worker;
  • Daytona as the first controller.

Product domains remain authoritative. Gateway connections remain connections, and trigger subscriptions remain subscriptions. Managed-resource rows only record how Agenta realizes those objects in external systems.

The Daytona credential plan and provider adapter from PR A must remain unchanged when PR B is added.

Why make the durability layer reusable?

Agenta already has another concrete instance of the same failure class. Composio trigger creation happens before local subscription persistence, so a database failure can orphan the provider trigger. Provider deletion failures may also be followed by local deletion, which loses the durable cleanup record.

A later trigger-controller PR can adopt the same kernel. Future MCP authorization stays in gateway_connections, while any provider-created registration, token revocation, or gateway allocation can use managed-resource reconciliation behind that product contract.

This PR does not migrate connections or triggers. They demonstrate that the control-plane primitive has a real second consumer.

Existing Agenta patterns

The proposal reuses:

  • standard Router -> Service -> DAO interface -> Postgres DAO layering;
  • provider adapters and registries from gateway/connections;
  • TaskIQ and Redis streams for worker execution;
  • lock and fencing lessons from sessions and evaluation jobs.

No generic durable external-resource reconciler exists in Agenta today.

Migration direction

Migration 011 has not shipped and has no production data. Replace the Daytona-specific agent_secret_leases schema before merge. Do not ship that schema and immediately add a production rename migration.

Preview databases that already applied migration 011 can downgrade to 010 and apply the replacement migration.

Detailed plan

Current verification

The existing implementation has extensive SDK, runner, API, migration, and live control-plane coverage. That work remains useful source material for the recut. This planning revision changes documentation only.

Review focus

Please review the domain boundary before reviewing implementation details:

  1. Should the feature be recut into functional materialization and durable reconciliation?
  2. Is managed external resources the right reusable scope?
  3. Should desired/observed state replace the Daytona-specific transition API?
  4. What standard workload identity should runner and workers use?
  5. Does the generic schema keep enough provider detail outside universal columns?

Do not approve the migration or API subsystem until those questions are settled.

@vercel

vercel Bot commented Jul 11, 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, Comment Jul 13, 2026 12:37am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d49d4c1f-8cfb-4da6-8801-1a1f6d53d95f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.74% 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 summarizes the main change: delivering agent credentials through Daytona Secrets.
Description check ✅ Passed The description is directly related to the changeset and review context, even though it discusses recutting the work into two PRs.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/daytona-secret-delivery

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.

};
return {
prepare,
async prepareForCreate() {

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.

Review focus: a cached active lease is valid only for a successful reconnect. Any fresh create first retires and cleans that lease, then reserves/provisions a new attempt. This closes the reviewed failure path where reconnect failed, activation targeted an already-active lease, and the newly created sandbox could leak.

AgentSecretLeaseDBE.owner_kind == query.owner.kind.value,
AgentSecretLeaseDBE.owner_id == query.owner.id,
)
if query.windowing.next:

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.

Review focus: retry time is only an eligibility filter. Page traversal uses immutable (created_at, id), so recordRetry cannot move a row across the cursor during a janitor sweep. The migration and DBE indexes use the same tuple.

Pass an existing --env-file (e.g. --env-file .env.$LICENSE.$STAGE.local) or create the file."
fi

# Never place runner control material in the shared service env file: every service

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.

Review focus: the shared Compose env file is injected into many services, so runner control material is rejected there. The dedicated second env file is interpolation-only; explicit Compose mappings give the control token to API+runner and the epoch HMAC key to runner only. This boundary was also verified inside the live containers.

return {"kind": self.kind, "name": self.name}


class ResolvedCredential(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.

Review focus: every resolved secret is classified by binding and usage. opaque_http credentials must go through Daytona Secrets on remote runs; local_use remains the explicit non-isolated exception. The value is excluded from repr, redaction is seeded before dispatch, and no delivery-policy field is exposed publicly.

@mmabrouk

Copy link
Copy Markdown
Member Author

This is a second implementation version. Here is what changed after the independent security review:

  • Fixed the active-lease reconnect failure path so a fresh sandbox cannot leak.
  • Made every Daytona Secret create, lookup, and recovery verify the deterministic name and exactly one expected host.
  • Changed janitor traversal to immutable (created_at, id) cursors while keeping retry time as eligibility only.
  • Added a database claim-consistency constraint and verified migration upgrade/downgrade against PostgreSQL.
  • Removed isolated /tmp test overlays so all tests exercise repository code.
  • Split Compose runner secrets out of the shared service env file. Live containers now show control only in API+runner and HMAC only in runner.
  • Re-ran 121 runner tests, 42 API tests, the canonical cross-service fixture, and the live workload-auth route checks. The final independent re-review approved the current tree.

Suggested review order: typed SDK contract, runner plan/provider lifecycle, API lease state machine and DAO, then deployment wiring. I added inline comments at the four highest-risk boundaries.

One manual release gate remains intentionally open: a credit-consuming Daytona sandbox run needs explicit credit approval. The draft includes exact QA steps for that run.

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

Caution

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

⚠️ Outside diff range comments (2)
sdks/python/agenta/sdk/redaction/context.py (1)

19-26: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Don't cache the fallback redactor in the ContextVar
Redactor is mutable (_known, _bounded, _sorted_values), and the SDK call sites here don’t enter redaction_context(). On the first miss, get_active_redactor() stores a no-op instance in the current context, so later with_known_secrets() calls can keep accumulating state across logical runs in reused tasks. Return a fresh fallback without setting it, or make sure every request/job path is wrapped in redaction_context().

sdks/python/agenta/sdk/agents/connections/resolver.py (1)

131-143: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Silent credential drop when provider has no resolvable env var.

If self._api_key is set but self._env_var is unset and provider isn't in _PROVIDER_ENV_VARS, env stays empty and the new call reports credential_mode="runtime_provided" — misrepresenting an explicitly-supplied credential as "let the harness handle its own login." The key is silently dropped with no error.

🐛 Proposed fix
         provider = self._provider or model.provider or ""
         env: Dict[str, str] = {}
         if self._api_key:
             env_var = self._env_var or _PROVIDER_ENV_VARS.get(provider.lower())
-            if env_var:
-                env[env_var] = self._api_key
+            if not env_var:
+                raise ValueError(
+                    f"cannot resolve an env var for provider {provider!r}; "
+                    "pass env_var explicitly to StaticConnectionResolver"
+                )
+            env[env_var] = self._api_key
         endpoint = Endpoint(base_url=self._base_url) if self._base_url else None
🧹 Nitpick comments (10)
services/runner/package.json (1)

25-25: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Use @daytona/sdk instead of the deprecated package
@daytonaio/sdk@0.196.0 exists, but it’s marked as moved to @daytona/sdk with the same API. Switch this dependency to the new package name.

api/oss/tests/pytest/unit/agent_secret_leases/test_service.py (1)

253-287: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider a negative test for the "one child CAS update at a time" invariant.

This test only exercises the happy path (a single resource_updates entry per in-phase transition). Given the test's own name emphasizes updating "one child... at a time," and the PR objectives call out claim-consistency/fencing as a specific concern, a companion negative case (submitting two resource_updates in a single beginProvisioning/beginCleanup call and asserting it's rejected) would confirm the invariant is actually enforced rather than just exercised by omission.

services/runner/tests/unit/session-pool.test.ts (1)

464-517: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a test for transientCredentialsHash
This block only asserts .sandboxCredentialsHash. Add a case that changes toolCallback.authorization and asserts transientCredentialsHash changes independently of sandboxCredentialsHash.

services/runner/tests/unit/daytona-secret-plan.test.ts (1)

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

Confirm exactHttpsHost isn't a second, divergent SSRF guard.

This suite imports exactHttpsHost directly from daytona-secret-plan.ts, separate from the dedicated ssrf-guard.ts/ssrf-guard.test.ts in this same PR. Worth confirming these share one implementation (or at least an equivalent blocklist) rather than two independently-maintained SSRF checks that can drift — e.g. this list omits the cloud-metadata address (169.254.169.254) and RFC1918 private ranges that ssrf-guard presumably covers.

Also applies to: 10-10

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

121-130: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Inconsistent field name in the same doc block.

Line 125 was updated to reference plan.modelEnvironment, but line 124 (two lines above) still says "the resolved secrets are the sole authority." Mixed terminology in a security-critical "clear-then-apply" doc block can mislead future maintainers about which field is actually authoritative.

✏️ Suggested wording fix
    * Clear-then-apply (Security rule 5): on a MANAGED run (`credentialMode === "env"`) the
-   * resolved `secrets` are the sole authority, so the daemon must NOT inherit the sidecar's own
+   * resolved `plan.modelEnvironment` is the sole authority, so the daemon must NOT inherit the sidecar's own
    * provider keys (the caller applies only `plan.modelEnvironment`). When true, no `KNOWN_PROVIDER_ENV_VARS`
api/oss/src/dbs/postgres/agent_secret_leases/dbes.py (1)

102-102: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Name the sandbox_label uniqueness constraint explicitly sandbox_label is still using unique=True; switch it to a named UniqueConstraint so the schema object stays stable and matches the rest of this file.

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

125-131: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider canonicalizing key order before hashing for a truly stable epoch marker.

credentialEpochHmac relies on JSON.stringify over objects (consumer, binding) whose key order is only stable because construction is currently consistent; it isn't explicitly canonicalized. Since this hash drives rotation/cleanup decisions ("Deterministic Daytona Secret creation" per PR objectives), an incidental change in upstream key ordering would spuriously change the digest even for semantically identical plans. Building explicit ordered tuples (or sorting object keys) before stringifying would make the guarantee explicit rather than incidental.

api/oss/src/core/agent_secret_leases/dtos.py (1)

55-60: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Tighten ResourceBinding.name normalization beyond "no control chars/whitespace".

The check any(ord(char) < 33 for char in value) only excludes control characters and the space character; it still admits characters such as =, ;, $, backslashes, etc. Given binding names are used to build environment-variable/secret keys (e.g. OPENAI_API_KEY) and feed provider_secret_name generation, a stricter allow-list (e.g. [A-Za-z0-9_.\-]+, or a kind-specific pattern per BindingKind) would prevent malformed identifiers from propagating into Daytona secret/env names.

♻️ Proposed fix
+import re
+
 `@field_validator`("name")
 `@classmethod`
 def validate_name(cls, value: str) -> str:
-    if value != value.strip() or any(ord(char) < 33 for char in value):
+    if not re.fullmatch(r"[A-Za-z0-9_.\-]+", value):
         raise ValueError("binding name must be normalized")
     return value
api/oss/src/apis/fastapi/agent_secret_leases/models.py (1)

106-116: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicate consumer-key validation logic across API and core layers.

ConsumerResponse.validate_key mirrors ResourceConsumer.validate_key in api/oss/src/core/agent_secret_leases/dtos.py almost exactly. Consider extracting the MODEL/HTTP_MCP key-invariant check into a shared helper (e.g. in types.py) that both models call, to avoid the two copies drifting apart over time.

api/oss/src/apis/fastapi/agent_secret_leases/utils.py (1)

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

Deduplicate the runner-control header constant.

CONTROL_HEADER here and _RUNNER_CONTROL_HEADER in api/oss/src/middlewares/auth.py are two independent copies of the same literal ("X-Agenta-Runner-Control-Token"). A future rename of one without the other silently breaks the control-token auth path (middleware bypass and/or this dependency stop agreeing on the header name), with nothing to catch the drift.

Extract this into a single shared constant (e.g. alongside RunnerConfig in api/oss/src/utils/env.py, or a small shared constants module) and import it from both places.

♻️ Proposed fix
-CONTROL_HEADER = "X-Agenta-Runner-Control-Token"
+from oss.src.utils.env import RUNNER_CONTROL_HEADER as CONTROL_HEADER

(and update api/oss/src/middlewares/auth.py to import the same constant instead of defining _RUNNER_CONTROL_HEADER locally)


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f51de113-36a4-4f2f-b4f9-5679792236d7

📥 Commits

Reviewing files that changed from the base of the PR and between a8257c9 and c9f0b88.

⛔ Files ignored due to path filters (1)
  • services/runner/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (96)
  • api/entrypoints/routers.py
  • api/oss/databases/postgres/migrations/core_oss/versions/oss000000011_add_agent_secret_leases.py
  • api/oss/src/apis/fastapi/agent_secret_leases/__init__.py
  • api/oss/src/apis/fastapi/agent_secret_leases/models.py
  • api/oss/src/apis/fastapi/agent_secret_leases/router.py
  • api/oss/src/apis/fastapi/agent_secret_leases/utils.py
  • api/oss/src/core/agent_secret_leases/__init__.py
  • api/oss/src/core/agent_secret_leases/dtos.py
  • api/oss/src/core/agent_secret_leases/interfaces.py
  • api/oss/src/core/agent_secret_leases/service.py
  • api/oss/src/core/agent_secret_leases/types.py
  • api/oss/src/dbs/postgres/agent_secret_leases/__init__.py
  • api/oss/src/dbs/postgres/agent_secret_leases/dao.py
  • api/oss/src/dbs/postgres/agent_secret_leases/dbes.py
  • api/oss/src/dbs/postgres/agent_secret_leases/mappings.py
  • api/oss/src/middlewares/auth.py
  • api/oss/src/utils/env.py
  • api/oss/tests/pytest/unit/agent_secret_leases/__init__.py
  • api/oss/tests/pytest/unit/agent_secret_leases/fixtures/secret_lease_wire.json
  • api/oss/tests/pytest/unit/agent_secret_leases/test_auth_cursor.py
  • api/oss/tests/pytest/unit/agent_secret_leases/test_models.py
  • api/oss/tests/pytest/unit/agent_secret_leases/test_schema.py
  • api/oss/tests/pytest/unit/agent_secret_leases/test_service.py
  • hosting/docker-compose/ee/docker-compose.dev.yml
  • hosting/docker-compose/ee/docker-compose.gh.local.yml
  • hosting/docker-compose/ee/docker-compose.gh.yml
  • hosting/docker-compose/oss/docker-compose.dev.yml
  • hosting/docker-compose/oss/docker-compose.gh.local.yml
  • hosting/docker-compose/oss/docker-compose.gh.ssl.yml
  • hosting/docker-compose/oss/docker-compose.gh.yml
  • hosting/docker-compose/run.sh
  • hosting/docker-compose/runner-secrets.env.example
  • hosting/kubernetes/helm/templates/api-deployment.yaml
  • hosting/kubernetes/helm/templates/runner-deployment.yaml
  • hosting/kubernetes/helm/templates/secrets.yaml
  • hosting/kubernetes/helm/values.schema.json
  • hosting/kubernetes/helm/values.yaml
  • hosting/railway/oss/scripts/configure.sh
  • sdks/python/agenta/sdk/agents/adapters/sandbox_agent.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.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_wire_contract.py
  • sdks/python/oss/tests/pytest/unit/agents/test_wire_models.py
  • services/runner/package.json
  • services/runner/src/engines/sandbox_agent.ts
  • services/runner/src/engines/sandbox_agent/daemon.ts
  • services/runner/src/engines/sandbox_agent/daytona-secret-janitor.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/errors.ts
  • services/runner/src/engines/sandbox_agent/mcp.ts
  • services/runner/src/engines/sandbox_agent/pause.ts
  • services/runner/src/engines/sandbox_agent/provider.ts
  • services/runner/src/engines/sandbox_agent/run-plan.ts
  • services/runner/src/engines/sandbox_agent/secret-lease-control.ts
  • services/runner/src/engines/sandbox_agent/session-pool.ts
  • services/runner/src/protocol.ts
  • services/runner/src/server.ts
  • services/runner/tests/unit/daytona-secret-janitor.test.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/mcp-servers.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-run-plan.test.ts
  • services/runner/tests/unit/secret-lease-control.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/ssrf-guard.test.ts
  • services/runner/tests/unit/wire-contract.test.ts
💤 Files with no reviewable changes (1)
  • sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py

Comment on lines +32 to +58
_ALLOWED_SOURCE_STATES = {
LeaseTransition.BEGIN_PROVISIONING: {
LeaseState.RESERVED,
LeaseState.PROVISIONING,
},
LeaseTransition.RECORD_SANDBOX: {LeaseState.PROVISIONING},
LeaseTransition.ACTIVATE: {LeaseState.PROVISIONING},
LeaseTransition.REQUEST_CLEANUP: {
LeaseState.RESERVED,
LeaseState.PROVISIONING,
LeaseState.ACTIVE,
LeaseState.CLEANING,
},
LeaseTransition.BEGIN_CLEANUP: {
LeaseState.CLEANUP_PENDING,
LeaseState.CLEANING,
},
LeaseTransition.RECORD_RETRY: {LeaseState.CLEANING},
LeaseTransition.MARK_DELETED: {LeaseState.CLEANING},
LeaseTransition.QUARANTINE: {
LeaseState.RESERVED,
LeaseState.PROVISIONING,
LeaseState.ACTIVE,
LeaseState.CLEANUP_PENDING,
LeaseState.CLEANING,
},
}

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 & Privacy | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
cat -n api/oss/src/dbs/postgres/agent_secret_leases/dao.py | sed -n '1,400p'
rg -n 'require_claim|janitor' api/oss/src/dbs/postgres/agent_secret_leases/dao.py api/oss/src/apis/fastapi/agent_secret_leases/models.py

Repository: Agenta-AI/agenta

Length of output: 19361


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== service.py (key sections) ==\n'
cat -n api/oss/src/core/agent_secret_leases/service.py | sed -n '1,260p'

printf '\n== router.py (mutate endpoint) ==\n'
cat -n api/oss/src/apis/fastapi/agent_secret_leases/router.py | sed -n '1,260p'

printf '\n== models.py (request/route inputs) ==\n'
cat -n api/oss/src/apis/fastapi/agent_secret_leases/models.py | sed -n '1,260p'

printf '\n== additional janitor/claim references in core agent_secret_leases ==\n'
rg -n 'janitor|require_claim|BEGIN_CLEANUP|RECORD_RETRY|MARK_DELETED|QUARANTINE|validate_mutation' \
  api/oss/src/core/agent_secret_leases \
  api/oss/src/apis/fastapi/agent_secret_leases \
  api/oss/src/dbs/postgres/agent_secret_leases

Repository: Agenta-AI/agenta

Length of output: 31363


Restrict cleanup transitions to janitor callers. BEGIN_CLEANUP, RECORD_RETRY, and MARK_DELETED are still reachable from the normal tenant PATCH path: the router passes janitor=False, and both validate_mutation and dao.mutate only enforce claim ownership when require_claim=True. A tenant can therefore advance a lease into cleanup/deleted states and mark resources deleted in the DB without a janitor claim, breaking the fenced-claim flow and risking orphaned Daytona secrets/sandboxes.

Comment on lines +203 to +226
now = datetime.now(timezone.utc)
async with self.engine.session() as session:
stmt = (
select(AgentSecretLeaseDBE)
.options(selectinload(AgentSecretLeaseDBE.resources))
.where(
AgentSecretLeaseDBE.id == lease_id,
AgentSecretLeaseDBE.version == mutation.expected_version,
)
.with_for_update()
)
if scope is not None:
stmt = self._scope(stmt, scope)
lease = (await session.execute(stmt)).scalar_one_or_none()
if lease is None:
return None
if require_claim and (
mutation.claim is None
or lease.claim_id != mutation.claim.id
or lease.claim_generation != mutation.claim.generation
or lease.claim_expires_at is None
or lease.claim_expires_at <= now
):
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

TOCTOU: now is captured before the row lock is acquired, weakening claim fencing.

now is computed before SELECT ... FOR UPDATE executes. If that statement blocks on lock contention, the claim-expiry check (lease.claim_expires_at <= now) uses a stale, earlier timestamp — an actually-expired claim (expired between the stale now and the real current time) can still pass fencing once the lock is finally acquired. This directly weakens the janitor claim-fencing guarantee this PR is meant to harden.

🔒️ Proposed fix
     async def mutate(
         self,
         *,
         lease_id: UUID,
         scope: Optional[TenantScope],
         mutation: LeaseMutation,
         require_claim: bool,
     ) -> Optional[AgentSecretLease]:
-        now = datetime.now(timezone.utc)
         async with self.engine.session() as session:
             stmt = (
                 select(AgentSecretLeaseDBE)
                 .options(selectinload(AgentSecretLeaseDBE.resources))
                 .where(
                     AgentSecretLeaseDBE.id == lease_id,
                     AgentSecretLeaseDBE.version == mutation.expected_version,
                 )
                 .with_for_update()
             )
             if scope is not None:
                 stmt = self._scope(stmt, scope)
             lease = (await session.execute(stmt)).scalar_one_or_none()
             if lease is None:
                 return None
+            now = datetime.now(timezone.utc)
             if require_claim and (
📝 Committable suggestion

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

Suggested change
now = datetime.now(timezone.utc)
async with self.engine.session() as session:
stmt = (
select(AgentSecretLeaseDBE)
.options(selectinload(AgentSecretLeaseDBE.resources))
.where(
AgentSecretLeaseDBE.id == lease_id,
AgentSecretLeaseDBE.version == mutation.expected_version,
)
.with_for_update()
)
if scope is not None:
stmt = self._scope(stmt, scope)
lease = (await session.execute(stmt)).scalar_one_or_none()
if lease is None:
return None
if require_claim and (
mutation.claim is None
or lease.claim_id != mutation.claim.id
or lease.claim_generation != mutation.claim.generation
or lease.claim_expires_at is None
or lease.claim_expires_at <= now
):
return None
async with self.engine.session() as session:
stmt = (
select(AgentSecretLeaseDBE)
.options(selectinload(AgentSecretLeaseDBE.resources))
.where(
AgentSecretLeaseDBE.id == lease_id,
AgentSecretLeaseDBE.version == mutation.expected_version,
)
.with_for_update()
)
if scope is not None:
stmt = self._scope(stmt, scope)
lease = (await session.execute(stmt)).scalar_one_or_none()
if lease is None:
return None
now = datetime.now(timezone.utc)
if require_claim and (
mutation.claim is None
or lease.claim_id != mutation.claim.id
or lease.claim_generation != mutation.claim.generation
or lease.claim_expires_at is None
or lease.claim_expires_at <= now
):
return None

Comment on lines +227 to +234
if any(item.usage == "opaque_http" for item in self.credentials):
base_url = self.endpoint.base_url if self.endpoint else None
parsed = urlparse(base_url or "")
if parsed.scheme.lower() != "https" or not parsed.hostname:
raise ValueError(
"opaque_http model credentials require an effective HTTPS endpoint"
)
return self

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 & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate the existing private-host / SSRF check used for MCP HTTP URLs, to confirm
# whether it can be shared with connections/models.py instead of duplicating logic.
rg -n "unsafe url|private|internal" sdks/python/agenta/sdk/agents/mcp/resolver.py -A3 -B3

Repository: Agenta-AI/agenta

Length of output: 490


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the connection model validator and the MCP SSRF guard helper.
files=(
  "sdks/python/agenta/sdk/agents/connections/models.py"
  "sdks/python/agenta/sdk/agents/mcp/resolver.py"
)
for f in "${files[@]}"; do
  echo "===== $f ====="
  wc -l "$f"
done

echo "===== models.py outline ====="
ast-grep outline sdks/python/agenta/sdk/agents/connections/models.py --view expanded

echo "===== resolver.py outline ====="
ast-grep outline sdks/python/agenta/sdk/agents/mcp/resolver.py --view expanded

Repository: Agenta-AI/agenta

Length of output: 2683


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant slices around the opaque_http validation and the URL-allowance helper.
sed -n '210,250p' sdks/python/agenta/sdk/agents/connections/models.py
echo "-----"
sed -n '1,140p' sdks/python/agenta/sdk/agents/mcp/resolver.py

Repository: Agenta-AI/agenta

Length of output: 5320


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for reusable URL safety helpers and tests around private/internal host rejection.
rg -n "assert_endpoint_url_allowed|is_private|unsafe url|127\\.0\\.0\\.1|localhost|private.*host|internal.*host" sdks/python/agenta -S

Repository: Agenta-AI/agenta

Length of output: 1898


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the helper implementation and its tests so the review comment can point at the
# existing SSRF guard accurately.
rg -n "def assert_endpoint_url_allowed|assert_endpoint_url_allowed\(" sdks/python/agenta -S
echo "-----"
rg -n "test_unsafe_http_mcp_url_is_a_configuration_error|ssrf-guard" sdks/python/agenta -S

Repository: Agenta-AI/agenta

Length of output: 566


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect where ResolvedConnection.endpoint/base_url comes from and whether custom deployments
# can carry arbitrary URLs.
rg -n "ResolvedConnection|base_url|deployment=\"custom\"|custom\".*base_url|endpoint:" sdks/python/agenta/sdk -S

Repository: Agenta-AI/agenta

Length of output: 9292


Reject private/internal hosts for opaque_http endpoints too. HTTPS plus a hostname still allows localhost/private destinations; reuse assert_endpoint_url_allowed (or the same host-safety check) here so secret-bearing credentials can’t be sent to an unsafe custom base_url.

Comment on lines +675 to 681
# ``model`` stays the plain string handed to the harness. ``model_ref`` carries author
# intent for resolution; only the resolved connection crosses the runner boundary.
model: Optional[str] = None
model_ref: Optional[ModelRef] = None
# ``resolved_connection`` carries the least-privilege output of a ``ConnectionResolver``
# (threaded down from ``SessionConfig``). It is the authoritative source of the non-secret
# provider/model descriptor on the wire when present; unset leaves the wire unchanged (the
# golden contract). Its ``env`` is the secret channel and never reaches the wire here (it
# rides ``secrets``). See :meth:`wire_resolved_connection`.
# ``resolved_connection`` carries the route and typed credential bindings produced by the
# resolver. It serializes as one consumer-owned ``modelConnection`` object.
resolved_connection: Optional[ResolvedConnection] = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Verify self.model still reaches the wire payload without colliding with resolved_connection.model.

self.model is now documented as the plain string handed to the harness, distinct from resolved_connection.model (the resolver's own model field). But wire_model_connection() never reads self.model — it only emits "model" (sourced from self.resolved_connection.model) when resolved_connection is set, and emits nothing at all otherwise.

This raises two open questions that this snippet alone can't resolve:

  • Is self.model serialized through some other path when resolved_connection is None (harness-login / no-resolved-connection runs), or is it silently dropped from the wire?
  • When resolved_connection IS set, does merging wire_model_connection()'s "model" key with any generic field dump of self.model risk one silently overwriting the other under the same "model" wire key?

Given this directly affects which model the harness receives, please confirm against the wire-assembly code and the run_request.*.json golden fixtures.

#!/bin/bash
rg -n "def to_wire|wire_model_connection|self\.model\b" sdks/python/agenta/sdk/agents/dtos.py
rg -n '"model"' 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

Also applies to: 771-778

Comment on lines +55 to +74
export function exactHttpsHost(rawUrl: string): string {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return fail("credential endpoint is not a valid URL");
}
if (url.protocol !== "https:") fail("credential endpoint must use HTTPS");
if (url.username || url.password || url.hash) fail("credential endpoint contains prohibited URL components");
if (url.port && url.port !== "443") fail("explicit non-default ports are not supported");
const host = url.hostname.toLowerCase().replace(/\.$/, "");
if (!host || host === "localhost" || host.endsWith(".localhost") || host.includes("*")) {
fail("credential endpoint host is prohibited");
}
const ipVersion = isIP(host);
if ((ipVersion === 4 && privateIpv4(host)) || ipVersion === 6) {
fail("credential endpoint must not be a private, reserved, or IPv6 literal");
}
return host;
}

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 & Privacy | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== relevant source =="
git ls-files 'services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts'
nl -ba services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts | sed -n '1,140p'

echo
echo "== search for exactHttpsHost usage =="
rg -n "exactHttpsHost|allowedHost|credential endpoint" services/runner/src/engines/sandbox_agent -S

echo
echo "== runtime behavior probe =="
node - <<'JS'
const { isIP } = require('node:net');
for (const raw of [
  'https://[::1]/x',
  'https://[fc00::1]/x',
  'https://127.0.0.1/x',
  'https://example.com/x',
]) {
  const u = new URL(raw);
  const host = u.hostname.toLowerCase().replace(/\.$/, '');
  const bare = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
  console.log({ raw, hostname: u.hostname, host, bare, isIP_host: isIP(host), isIP_bare: isIP(bare) });
}
JS

Repository: Agenta-AI/agenta

Length of output: 281


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== source =="
sed -n '1,140p' services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts | cat -n

echo
echo "== usages =="
rg -n "exactHttpsHost|allowedHost|credential endpoint" services/runner/src/engines/sandbox_agent -S

echo
echo "== runtime probe =="
node - <<'JS'
const { isIP } = require('node:net');
for (const raw of [
  'https://[::1]/x',
  'https://[fc00::1]/x',
  'https://127.0.0.1/x',
  'https://example.com/x',
]) {
  const u = new URL(raw);
  const host = u.hostname.toLowerCase().replace(/\.$/, '');
  const bare = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
  console.log(JSON.stringify({
    raw,
    hostname: u.hostname,
    host,
    bare,
    isIP_host: isIP(host),
    isIP_bare: isIP(bare),
  }));
}
JS

Repository: Agenta-AI/agenta

Length of output: 12040


Strip brackets before the IP check. url.hostname keeps IPv6 literals in bracket form ([::1]), so net.isIP(host) returns 0 and bracketed IPv6 hosts slip through as approved allowedHost values.

traceparent = headers.get("traceparent")
if not traceparent:
authorization = headers.get("Authorization")
if not traceparent and not authorization:

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 AI agent says: Hotfix review note: this preserves invoke authorization when no traceparent is present, which unblocks the current playground path without adding a wire field or treating the gateway callback token as tenant authorization. This is intentionally narrow. The follow-up architecture should separate platform/control-plane authorization from telemetry context in a typed SDK field so authorization does not depend on tracing being enabled.

@mmabrouk

Copy link
Copy Markdown
Member Author

🤖 The AI agent says: Hotfix and live QA update

The SDK now preserves invoke authorization even when an invocation has no traceparent (5bef9b850f). Focused SDK regression tests pass 6/6, Ruff passes, and an independent review approved the narrow change.

Live verification on the playground stack:

  • the original composite prerequisite error is gone;
  • the runner receives invoke credentials (cred=present);
  • Daytona secret-lease reserve, claim, terminal patch, and cleanup calls all complete with HTTP 200;
  • the subscription sidecar has the internal control token and HMAC key, while OPENAI_API_KEY and ANTHROPIC_API_KEY remain absent;
  • the supplied stored revision succeeds with HTTP 200 and PONG on the correct /services/agent/v0/invoke endpoint;
  • a Daytona + opaque Anthropic credential run now reaches Daytona Secret provisioning, where the configured Daytona API key returns Access denied.

So the hotfix is working, but the current environment still needs a Daytona API key/role with Secrets CRUD permission before opaque Daytona playground runs can complete.

Follow-up required: move platform/control-plane authorization out of TraceContext into a typed internal SDK/run-context field. This hotfix must not become the permanent coupling between authorization and telemetry.

try {
await sandbox.start();
} catch (error) {
if (!isStateChangeInProgress(error)) throw error;

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 AI agent says: Review note: this handles the exact warm-eviction race observed in the playground. Daytona may report Sandbox state change in progress when start() overlaps an in-flight stop/delete. We retry only that explicit transient message, refresh the provider state between attempts, preserve terminal not-found handling, and share the existing 10-second reconnect deadline so retries remain bounded.

@mmabrouk

Copy link
Copy Markdown
Member Author

🤖 The AI agent says: Added the Daytona warm-session transition-race fix requested during live QA.

Observed behavior: a config or credential change evicted the warm session while the next request attempted to reconnect. Daytona returned Sandbox state change in progress, and the provider error reached the playground.

Changed in fff1849dda:

  • retry only Daytona’s explicit transient state-transition conflict;
  • refresh state before retrying;
  • retain terminal handling for missing, destroyed, error, and unknown sandboxes;
  • bound the entire reconnect attempt to the existing 10-second deadline;
  • add a regression test that reproduces a stop transition between refresh and start().

Verification:

  • focused Daytona lifecycle suite: 22/22 passed;
  • runner TypeScript check: passed;
  • live sidecar restarted with the fix;
  • warm-session settings remain enabled at the existing defaults.

I also corrected the local sidecar mount for the already-built in-sandbox tool MCP shim. That was deployment state only, not a repository change.

@mmabrouk

Copy link
Copy Markdown
Member Author

🤖 The AI agent says:

🔸 Decision needed: approve the #5242 recut before any more implementation work.

I recommend two stacked PRs:

  1. PR A contains typed credential delivery and Daytona Secret materialization only. It has no API migration or durable control-plane routes and remains disabled for production because a hard runner crash can orphan resources.
  2. PR B adds a reviewed managed-resources control plane with desired/observed state, retries, claims, fencing, and a reconciler worker. Daytona is its first controller.

The repository research found a concrete second consumer: Composio trigger subscriptions currently create the provider trigger before local persistence and can lose cleanup state after provider deletion failures. Gateway connections have a similar lifecycle gap. This supports a reusable external-resource kernel, but neither product domain should be replaced by it.

Please decide:

  • Approve or reject the two-PR split.
  • Approve or reject managed external resources as the reusable boundary.
  • Confirm that the generic control plane must use a standard workload identity and must not carry authorization through telemetry or grow feature-specific bypasses in global auth middleware.

Detailed design and API links are now in the PR description. I recommend reviewing the architecture and migration direction before reviewing the current lease implementation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant