Skip to content

[fix] Stop blaming the user's key for unsubstituted sandbox credentials - #6362

Merged
mmabrouk merged 2 commits into
release/v0.114.4from
fix/runner-placeholder-401-copy
Aug 31, 2026
Merged

[fix] Stop blaming the user's key for unsubstituted sandbox credentials#6362
mmabrouk merged 2 commits into
release/v0.114.4from
fix/runner-placeholder-401-copy

Conversation

@mmabrouk

Copy link
Copy Markdown
Member

Context

A free-credits user on EU cloud ran Gemini in the playground and got: "pi_core: model authentication failed. Add the project's OpenAI key to the project vault, or log in (OAuth)." Every part of that advice was wrong. The key was fine, the user never had an OpenAI key, and the model was Gemini.

The real cause, found in the LiteLLM proxy log: the sandbox sent its Daytona Secret placeholder instead of the real key. Daytona substitutes dtn_secret_<id> placeholders into egress requests, and that substitution propagates asynchronously. When a fresh sandbox's first model call beats it (all 7 incidents in 72h were first calls, 10 to 24 seconds after Secret creation), the raw placeholder reaches the proxy and is refused:

401: LiteLLM Virtual Key expected. Received=dtn_****9maz, expected to start with 'sk-'.

The runner classified that as a generic 401 and blamed the user's key. The "OpenAI" wording came from the custom deployment's dialect family, so every custom-connection auth failure mislabels the key the same way.

Changes

The runner now recognizes the placeholder-shaped refusal as its own class.

Before:

pi_core: model authentication failed — add the project's OpenAI key to the project vault, or log in (OAuth).   (code: runner_error)

After:

A temporary issue kept this run's credentials from reaching the model. Send the message again.   (code: credential_delivery_failed)

Two more pieces:

  • A genuine auth failure on a custom-deployment connection now names the connection, not the dialect family: "add the 'starter-credits' connection's API key", never "the project's OpenAI key" for a run that goes through an OpenAI-compatible proxy.
  • The chat error card offers a Try again button for the transient classes (credential_delivery_failed, starter_credits_unavailable, rate_limited). It reuses the same regenerate wiring as the Stopped/Resend pair, on the last turn only, never while a run is busy.

The code wire field is an open string, so the new value needs no protocol or golden-fixture change.

Tests

  • services/runner: pnpm test (2538 passed) and pnpm run typecheck. New cases cover the LiteLLM placeholder body, a raw dtn_secret_ echo, the connection-named hint, and the non-custom fallback.
  • web/oss: AgentMessage.runError.test.tsx (5 passed) covers the Try again button appearing for credential_delivery_failed, staying hidden without a handler, and staying hidden for non-transient codes.
  • A follow-up PR will add the real guard: rebuild the sandbox and retry once when this class is hit, so users stop seeing it at all.

What to QA

  • Hard to trigger on demand (a ~3% race on cold sandbox starts). To fake it: fail a free-credits run and check the error card copy no longer mentions an OpenAI key.
  • Regression: a genuinely wrong key on a direct OpenAI connection still says "add the project's OpenAI key". A stopped run still shows the Stopped/Resend pair.

https://claude.ai/code/session_014s6jqKCsVKsNMmnrJVJkZt

A Daytona run whose opaque Secret placeholder reaches the model proxy raw
(substitution not yet propagated) failed with 'model authentication failed --
add the project's OpenAI key', which is wrong three ways for a free-credits
Gemini user: the key is fine, they never had an OpenAI key, and the advice
is unactionable. Found live on EU cloud 2026-08-29: 7 of 7 placeholder 401s
were the first call of a fresh sandbox, 10-24s after Secret creation.

- runner: recognize the placeholder-shaped refusal (LiteLLM 'Virtual Key
  expected. Received=dtn_...' and raw dtn_secret_ echoes) as its own
  transient class 'credential_delivery_failed' with retry-flavored copy.
- runner: a custom-deployment auth failure names the connection instead of
  the dialect family ('the starter-credits connection's API key', not 'the
  project's OpenAI key').
- web: transient failure classes (credential_delivery_failed,
  starter_credits_unavailable, rate_limited) offer a Try again button on the
  last turn, reusing the Stopped->Resend regenerate wiring.

Claude-Session: https://claude.ai/code/session_014s6jqKCsVKsNMmnrJVJkZt
@dosubot dosubot Bot added the size:M This PR changes 30-99 lines, ignoring generated files. label Aug 29, 2026
@vercel

vercel Bot commented Aug 29, 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 31, 2026 4:10pm

Request Review

@dosubot dosubot Bot added bug dev experience Improvement of the experience using the software. For instance better error messaging labels Aug 29, 2026
@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added a “Try again” option for retryable agent run failures, including temporary credential-delivery, credit, and rate-limit errors.
    • Retry is available for the latest failed turn when resending is supported.
    • Added warnings and confirmation before retrying conversations containing actions with side effects.
  • Bug Fixes

    • Correctly identifies credential placeholders returned before secrets are substituted and provides a clearer error message.
    • Improved authentication error hints for custom connections.
    • Cleared the stopped state when restarting a conversation.

Walkthrough

The runner detects unsubstituted Daytona credential placeholders and returns a dedicated transient error. Custom deployments use a neutral model-connection API-key hint. The chat UI offers “Try again” for retryable failures on the last resendable turn and confirms retries that may repeat side effects.

Changes

Credential delivery retry

Layer / File(s) Summary
Credential error classification
services/runner/src/engines/sandbox_agent/errors.ts, services/runner/tests/unit/sandbox-agent-errors.test.ts
Adds the credential_delivery_failed code, Daytona placeholder detection, a retry message, and a neutral API-key hint for custom deployments. Tests cover the updated classification.
Runner metadata wiring
services/runner/src/engines/sandbox_agent/run-turn.ts
Passes connection metadata through swallowed and caught error paths.
Chat retry and side-effect protection
web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx, web/oss/src/components/AgentChatSlice/components/AgentTurn.tsx, web/oss/src/components/AgentChatSlice/AgentConversation.tsx, web/oss/src/components/AgentChatSlice/components/AgentMessage.runError.test.tsx, web/packages/agenta-chat/tests/unit/assets/rewind.test.ts
Adds retry handling for transient errors on the last resendable turn. Retries confirm previously executed side effects and clear the stopped state. Tests cover retry availability and side-effect detection.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: 🟡 Moderate · up to a283f

The new Try again action can regenerate a failed turn whose write-capable tools may already have changed external state; because the current safeguards do not cover all interrupted or errored executions, a retry could repeat a write without warning. This bounded correctness and security risk should be fixed or explicitly accepted before merge.

Sequence Diagram(s)

sequenceDiagram
  participant runTurn
  participant classifyRunError
  participant AgentTurn
  participant AgentConversation
  participant RunErrorBody
  runTurn->>classifyRunError: pass connection metadata and raw error
  classifyRunError-->>runTurn: return credential_delivery_failed
  runTurn-->>AgentTurn: provide failed turn
  AgentTurn->>RunErrorBody: pass retry handler for the last turn
  RunErrorBody-->>AgentTurn: render Try again for retryable code
  AgentTurn->>AgentConversation: invoke resend
  AgentConversation-->>AgentTurn: confirm side effects before regeneration
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: preventing unsubstituted sandbox credentials from being incorrectly attributed to the user's key.
Description check ✅ Passed The description directly explains the credential-placeholder issue, the runner classification changes, retry behavior, safety checks, and test coverage.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 60.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 3 functions across 8 files.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/runner-placeholder-401-copy

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.

@mmabrouk
mmabrouk changed the base branch from release/v0.114.3 to main August 29, 2026 09:12
mmabrouk added a commit that referenced this pull request Aug 30, 2026
…rst call

The guard for the placeholder-401 race (#6362): Daytona applies a new
Secret's substitution rule asynchronously with no confirmation signal,
and ~3% of fresh sandboxes' first model calls on EU cloud carried the
raw dtn_ placeholder and failed the user's turn.

On a freshly created Daytona sandbox whose model key rides a Secret and
whose connection declares an endpoint base URL, the runner now probes
the credential's own /chat/completions from INSIDE the sandbox: a
response echoing dtn_ means the placeholder went through raw, so wait
2s and probe again (budget 25s); anything else means the header was
substituted and the run proceeds. The probe starts right after sandbox
create and is awaited at the END of acquire, so it runs concurrently
with the ~10s of mounts/workspace/session work and the common case pays
nothing.

Fail open everywhere: an exec error, an empty body, or a spent budget
logs and lets the run proceed — the worst outcome is the pre-existing
behavior, now at least classified as credential_delivery_failed. The
raw key value never appears runner-side: the shell expands the env var
inside the sandbox.

Every observed incident was a first call of a fresh sandbox (7 of 7),
so once one probe substitutes, the sandbox is good for its lifetime.

Claude-Session: https://claude.ai/code/session_014s6jqKCsVKsNMmnrJVJkZt
mmabrouk added a commit that referenced this pull request Aug 30, 2026
…rst call

The guard for the placeholder-401 race (#6362): Daytona applies a new
Secret's substitution rule asynchronously with no confirmation signal,
and ~3% of fresh sandboxes' first model calls on EU cloud carried the
raw dtn_ placeholder and failed the user's turn.

On a freshly created Daytona sandbox whose model key rides a Secret and
whose connection declares an endpoint base URL, the runner now probes
the credential's own /chat/completions from INSIDE the sandbox: a
response echoing dtn_ means the placeholder went through raw, so wait
2s and probe again (budget 25s); anything else means the header was
substituted and the run proceeds. The probe starts right after sandbox
create and is awaited at the END of acquire, so it runs concurrently
with the ~10s of mounts/workspace/session work and the common case pays
nothing.

Fail open everywhere: an exec error, an empty body, or a spent budget
logs and lets the run proceed — the worst outcome is the pre-existing
behavior, now at least classified as credential_delivery_failed. The
raw key value never appears runner-side: the shell expands the env var
inside the sandbox.

Every observed incident was a first call of a fresh sandbox (7 of 7),
so once one probe substitutes, the sandbox is good for its lifetime.

Claude-Session: https://claude.ai/code/session_014s6jqKCsVKsNMmnrJVJkZt
mmabrouk added a commit that referenced this pull request Aug 31, 2026
…rst call

The guard for the placeholder-401 race (#6362): Daytona applies a new
Secret's substitution rule asynchronously with no confirmation signal,
and ~3% of fresh sandboxes' first model calls on EU cloud carried the
raw dtn_ placeholder and failed the user's turn.

On a freshly created Daytona sandbox whose model key rides a Secret and
whose connection declares an endpoint base URL, the runner now probes
the credential's own /chat/completions from INSIDE the sandbox: a
response echoing dtn_ means the placeholder went through raw, so wait
2s and probe again (budget 25s); anything else means the header was
substituted and the run proceeds. The probe starts right after sandbox
create and is awaited at the END of acquire, so it runs concurrently
with the ~10s of mounts/workspace/session work and the common case pays
nothing.

Fail open everywhere: an exec error, an empty body, or a spent budget
logs and lets the run proceed — the worst outcome is the pre-existing
behavior, now at least classified as credential_delivery_failed. The
raw key value never appears runner-side: the shell expands the env var
inside the sandbox.

Every observed incident was a first call of a fresh sandbox (7 of 7),
so once one probe substitutes, the sandbox is good for its lifetime.

Claude-Session: https://claude.ai/code/session_014s6jqKCsVKsNMmnrJVJkZt
mmabrouk added a commit that referenced this pull request Aug 31, 2026
…rst call

The guard for the placeholder-401 race (#6362): Daytona applies a new
Secret's substitution rule asynchronously with no confirmation signal,
and ~3% of fresh sandboxes' first model calls on EU cloud carried the
raw dtn_ placeholder and failed the user's turn.

On a freshly created Daytona sandbox whose model key rides a Secret and
whose connection declares an endpoint base URL, the runner now probes
the credential's own /chat/completions from INSIDE the sandbox: a
response echoing dtn_ means the placeholder went through raw, so wait
2s and probe again (budget 25s); anything else means the header was
substituted and the run proceeds. The probe starts right after sandbox
create and is awaited at the END of acquire, so it runs concurrently
with the ~10s of mounts/workspace/session work and the common case pays
nothing.

Fail open everywhere: an exec error, an empty body, or a spent budget
logs and lets the run proceed — the worst outcome is the pre-existing
behavior, now at least classified as credential_delivery_failed. The
raw key value never appears runner-side: the shell expands the env var
inside the sandbox.

Every observed incident was a first call of a fresh sandbox (7 of 7),
so once one probe substitutes, the sandbox is good for its lifetime.

Claude-Session: https://claude.ai/code/session_014s6jqKCsVKsNMmnrJVJkZt
mmabrouk added a commit that referenced this pull request Aug 31, 2026
Adding a second integration to an agent evicted its warm session twice
over: the integration names were composed into the prompt strings
(appendSystemPrompt for Pi, agentsMd for the file-based harnesses) and
into the search_tools tool description, and both surfaces are part of
the session fingerprint. A one-word list change cost a full sandbox
rebuild, fresh Daytona Secrets, and the substitution race of #6362.

Now the guidance rides the wire as gatewayGuidance {text, carrier}:
- the SDK adapters keep the carrier choice but stop splicing; the
  prompt strings leave the adapter purely authored;
- the runner splices guidance-first at environment build (buildRunPlan),
  so the text refreshes exactly when a session is built and never evicts
  one: configFingerprint and the desired-state facets exclude the field
  by design, with the reason documented at both sites;
- the wording presents the names as examples ('For instance, some of
  the integrations you have: ... Others may exist, and this list can go
  stale — search_tools is the source of truth'), so a list that goes
  stale mid-session stays honest;
- the derived search_tools/run_tool descriptions drop the names sentence
  and are now byte-identical for any integration set, so only the FIRST
  connection (which genuinely adds the two tools) changes session config.

Golden run_request.gateway_connection.json pins the new field; the
connection-free payloads are byte-identical to before.

Claude-Session: https://claude.ai/code/session_014s6jqKCsVKsNMmnrJVJkZt
mmabrouk added a commit that referenced this pull request Aug 31, 2026
…rst call

The guard for the placeholder-401 race (#6362): Daytona applies a new
Secret's substitution rule asynchronously with no confirmation signal,
and ~3% of fresh sandboxes' first model calls on EU cloud carried the
raw dtn_ placeholder and failed the user's turn.

On a freshly created Daytona sandbox whose model key rides a Secret and
whose connection declares an endpoint base URL, the runner now probes
the credential's own /chat/completions from INSIDE the sandbox: a
response echoing dtn_ means the placeholder went through raw, so wait
2s and probe again (budget 25s); anything else means the header was
substituted and the run proceeds. The probe starts right after sandbox
create and is awaited at the END of acquire, so it runs concurrently
with the ~10s of mounts/workspace/session work and the common case pays
nothing.

Fail open everywhere: an exec error, an empty body, or a spent budget
logs and lets the run proceed — the worst outcome is the pre-existing
behavior, now at least classified as credential_delivery_failed. The
raw key value never appears runner-side: the shell expands the env var
inside the sandbox.

Every observed incident was a first call of a fresh sandbox (7 of 7),
so once one probe substitutes, the sandbox is good for its lifetime.

Claude-Session: https://claude.ai/code/session_014s6jqKCsVKsNMmnrJVJkZt

@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.

Review summary

Reviewed exact head 08ada4641c8d8a738f04c834ce2c5ec64ec216e5.

I recommend addressing the retry safety finding before merge.

1. High: Try again can repeat tools that already changed external systems

The new transient-error action passes directly to onResend, which calls regenerate({messageId}) without the side-effect check used by the existing rewind path.

A reproducible sequence is:

  1. A tool performs a write, such as creating a GitHub issue.
  2. A later model call in the same turn hits rate_limited or another retryable failure.
  3. The error card shows Try again.
  4. Clicking it removes the failed assistant turn, including the completed tool record, and resends only the preceding user message.
  5. The agent can perform the write again.

I confirmed with an AI SDK probe that regenerating an assistant message containing a completed tool-create_issue part sends only the preceding user message. The repository already has sideEffectingToolsInRange and a confirmation dialog in handleRewind for this exact risk.

Smallest useful fix: reuse that existing side-effect detection and confirmation before retrying, or hide retry when a completed write tool exists. Add one test for a completed write followed by rate_limited.

2. Medium: Starter-credit users receive an impossible key instruction

For every named custom deployment, keyHintFor now says to add the connection's API key to the project vault. The new test explicitly expects this for starter-credits.

The seeded starter-credit connection is managed by Agenta, write-only, hidden from Settings, and rejects general updates. A genuine non-placeholder 401 therefore tells the user to edit a connection they cannot edit. It also exposes the internal slug starter-credits instead of the user-facing name Agenta.

Smallest useful fix: use neutral custom-connection copy unless the runner knows that the credential is user-editable. Do not special-case the starter-credits slug because the seeding code explicitly says the slug is not proof of ownership.

Implementation constraint from Mahmoud

Only implement these fixes if they can stay small, local, and clear. Do not add unwarranted complexity, duplicate recovery logic, or grow a chain of special cases that makes the code hard to follow.

Prefer reusing the existing retry safety helper and warning path. For the managed-connection message, prefer honest neutral copy in this pull request. If a precise fix requires broad new wire fields or a large connection-metadata refactor, keep that work out of this pull request and record it as a focused follow-up instead.

Verification

  • Runner error tests: 33 passed locally.
  • Runner type check passed locally.
  • Web error-card tests: 5 passed locally.
  • Web type check and changed-file lint passed locally.
  • Rewind safety tests: 6 passed locally.
  • No existing submitted review or inline comment duplicated these findings.

@mmabrouk
mmabrouk changed the base branch from main to release/v0.114.4 August 31, 2026 16:03
Two review findings:

1. High: the error card's Try again passed straight to regenerate, skipping the
side-effect check the rewind path already has. A retryable model error can land
AFTER a completed write tool, and the retry would run the write again. The
resend handler now runs the same sideEffectingToolsInRange check and asks for
confirmation, with retry-specific copy. Pinned with a failed-turn range test.

2. Medium: the custom-deployment auth hint named the connection slug, but the
runner cannot tell a user-created connection from a managed hidden one
(starter-credits), so the hint could leak an internal slug and point the user
at a connection they cannot edit. The hint is now neutral.

Claude-Session: https://claude.ai/code/session_0165tsjmvf3qvTPFcb9EV44g
@mmabrouk

Copy link
Copy Markdown
Member Author

Both findings are addressed in a283fd8.

  1. Retry safety (High): the resend handler now runs the same sideEffectingToolsInRange check the rewind path uses, and asks for confirmation with retry-specific copy before it regenerates. This guards both the error card's Try again and the Stopped resend, which had the same hazard. A regression test pins the failed-turn range (a completed create_issue beside a rate_limited error part).

  2. Managed-connection copy (Medium): the custom-deployment hint is now neutral ("the model connection's API key") and never names the slug. The reason is recorded next to the code. No special case for starter-credits.

No new wire fields and no connection-metadata refactor; the user-editable flag stays a follow-up.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
services/runner/src/engines/sandbox_agent/errors.ts (1)

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

Align the interface documentation with the neutral hint.

keyHintFor no longer names connection.slug for custom deployments, but ConciseErrorOptions.connection still says that the hint names the connection. Update that description to document the neutral wording.

web/oss/src/components/AgentChatSlice/AgentConversation.tsx (1)

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

Keep each changed web comment to one short line.

  • web/oss/src/components/AgentChatSlice/AgentConversation.tsx#L625-L627: replace the three-line retry-safety comment with one concise line.
  • web/packages/agenta-chat/tests/unit/assets/rewind.test.ts#L66-L68: replace the three-line test rationale with one concise line.

As per coding guidelines: Hard rule. At most ONE short line per comment.

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

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

Review profile: CHILL

Plan: Pro Plus

Run ID: 6914e583-5a53-4cf8-a022-2eadcdc9572a

📥 Commits

Reviewing files that changed from the base of the PR and between 08ada46 and a283fd8.

📒 Files selected for processing (4)
  • services/runner/src/engines/sandbox_agent/errors.ts
  • services/runner/tests/unit/sandbox-agent-errors.test.ts
  • web/oss/src/components/AgentChatSlice/AgentConversation.tsx
  • web/packages/agenta-chat/tests/unit/assets/rewind.test.ts

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

@mmabrouk
mmabrouk merged commit 893b551 into release/v0.114.4 Aug 31, 2026
52 of 53 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug dev experience Improvement of the experience using the software. For instance better error messaging size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant