Skip to content

[feat] Detect a stuck-substitution sandbox before the first model call and rebuild it - #6370

Merged
mmabrouk merged 5 commits into
release/v0.114.4from
feat/credential-preflight
Aug 31, 2026
Merged

[feat] Detect a stuck-substitution sandbox before the first model call and rebuild it#6370
mmabrouk merged 5 commits into
release/v0.114.4from
feat/credential-preflight

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Aug 29, 2026

Copy link
Copy Markdown
Member

Context

The placeholder-401 fault (#6362), now fully reproduced (2026-08-30, 20 fresh-Secret samples, production create shape, target eu): Daytona's Secret substitution is binary per sandbox. A healthy sandbox substitutes on its very first request, 1.5 to 2.9 seconds after Secret creation. A stuck sandbox never substitutes: the raw dtn_secret_ placeholder reaches the provider for as long as anyone watches, a twin sandbox created against the same Secret works immediately, and stop+start does not repair it. Measured 5 stuck of 20 that day; the earlier production window showed ~3%, so the rate varies. The delete-then-create eviction ordering is not a factor.

Two instrument facts worth keeping: Daytona also scrubs responses (real values are rewritten back to placeholders before entering the sandbox), so an echo service cannot observe substitution; a provider whose error body echoes a masked key can. And a raw-placeholder echo stays visible, which is what this preflight reads.

Changes

On a freshly created Daytona sandbox whose model key rides a Secret and whose connection declares an endpoint base URL, the runner probes the credential's own /chat/completions from inside the sandbox, concurrently with the rest of acquire.

Before (the first version of this PR): wait up to 25 seconds for substitution, then proceed regardless. Both halves were wrong for a binary fault: the wait can never help, and proceeding sends the turn into a guaranteed 401.

After: the preflight returns a verdict. The first non-placeholder echo (or anything ambiguous, which still fails open) is ok. A raw placeholder still echoing at the 10 second grace convicts the sandbox as stuck (deliberately below Daytona's ~30s keep-or-recreate bound: every healthy sandbox we measured answered on its first probe, their wiring fix is in progress, and a longer grace only holds a stuck user's turn); the acquire's shared teardown destroys it and deletes its Secrets, and the acquire wrapper retries once with a brand-new sandbox, which their support confirms is the correct recovery (restart never helps; a new sandbox on the same Secret does). At a 25% stuck rate, one retry turns a failed first turn into a slower one, leaving ~6% for the honest credential_delivery_failed error. Their side has a priority fix PR in progress; this guard stays as the belt afterwards.

Tests

  • Preflight unit suite: first-probe ok, tolerated early raws then ok, 4-raw conviction, budget conviction, exec-error fail-open, empty-body fail-open. Full runner suite green (157 files / 2580 tests), typecheck clean.
  • Live verification: after deploy, the runner log shows [credential-preflight] STUCK ... rebuilding fresh where placeholder 401s used to be, and the LiteLLM proxy log's Received=dtn_ count trends to the ~6% residual of the varying stuck rate.

The reproduction record lives in docs/design/daytona-secret-propagation/README.md (stacked below, #6369).

Stacked on #6369.

https://claude.ai/code/session_014s6jqKCsVKsNMmnrJVJkZt

@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 3:51pm

Request Review

@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Aug 29, 2026
@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • release/.*

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: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: bd694ca1-7ed6-43c2-970f-adfa99fb4173

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

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 force-pushed the feat/credential-preflight branch from 66bf2da to c5f0f39 Compare August 30, 2026 09:29
@mmabrouk
mmabrouk force-pushed the feat/credential-preflight branch from c5f0f39 to ca7d734 Compare August 30, 2026 10:12
@mmabrouk mmabrouk changed the title [feat] Wait out the Daytona Secret substitution race before the first model call [feat] Detect a stuck-substitution sandbox before the first model call and rebuild it Aug 30, 2026

@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

  • Risky: services/runner/src/engines/sandbox_agent/credential-preflight.ts:129 - a healthy sandbox can be classified as stuck when Daytona response scrubbing rewrites an echoed real credential back to a dtn_secret_ placeholder.
  • Missing tests: services/runner/tests/unit/credential-preflight.test.ts needs a healthy, scrubbed-response regression case.

Reviewed exact head ca7d73402fd2e616cbaada5a9b0c26752c5965af. I recommend fixing this false-positive path before merging.

return "ok";
}
const elapsedMs = now() - startedAt;
if (!body || !body.includes("dtn_")) {

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.

High severity: This condition cannot distinguish a raw placeholder request from a healthy request whose real credential was echoed by the endpoint and then scrubbed by Daytona. The reproduction notes already establish that Daytona rewrites real credential values in responses back to dtn_secret_.... For a custom OpenAI-compatible endpoint that echoes the Authorization header in its {} error response, every healthy probe therefore contains dtn_, both acquisition attempts are destroyed, and the first turn fails even though the real model call would work.

Please only convict on the proven provider-specific masked raw-placeholder signature, such as LiteLLM's specific Received=dtn_**** shape, and fail open for other dtn_ bodies. Add a regression test where a healthy echoed credential has been scrubbed into a full placeholder.

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.

You are right, and the module doc was arguing against itself — it cited the scrubbing behaviour and then convicted on a bare dtn_. Fixed in a8bfc83.

The preflight now convicts only on a MASKED placeholder echo, which is what scrubbing cannot forge: masking removes the real value, so there is nothing left for the scrubber to match, and a dtn_ beside a mask can only mean the raw placeholder really went out. The regex covers both proven shapes (LiteLLM Received=dtn_****, OpenAI dtn_secr*****) plus LiteLLM naming a dtn_ key as what it received, mirroring PLACEHOLDER_CREDENTIAL in errors.ts. * is the only mask character trusted — a ... or truncation could equally be a cut-off scrubbed value, so it fails open.

An unmasked dtn_ body now returns ok on the first probe and logs why it convicts nothing, so a stuck sandbox behind an echoing endpoint surfaces as the 401 rather than as two destroyed acquire attempts.

Regression test added for exactly your case (fails open when a HEALTHY echoed key was scrubbed into a full placeholder), plus one asserting both masked shapes still convict. Detection is unchanged in practice: every incident in production and in the 20-sandbox probe carried a masked echo.

…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
The reproduction proved the fault is binary per sandbox and permanent:
a healthy sandbox substitutes on its FIRST request (~2s), a stuck one
never does, and a fresh sandbox on the same Secret works immediately.
So the preflight's original wait-25s-then-proceed design was wrong on
both ends — the wait can never help, and proceeding sends the turn into
a guaranteed 401.

The preflight now returns a verdict: 'ok' on the first non-placeholder
echo (or any ambiguity — ambiguity still fails open), 'stuck' after 4
consecutive raw-placeholder echoes. A stuck verdict throws through the
acquire's shared teardown (sandbox destroyed, Secrets deleted) and the
acquire wrapper retries ONCE with a brand-new sandbox. At the measured
25% stuck rate that turns a failed first turn into a slower one, with
~6% residual for the honest credential_delivery_failed error.

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

Their support (2026-08-31) confirmed the per-sandbox wiring failure and
stated the operating rule: a sandbox whose retry starts working within
~30s may be kept; one still sending the placeholder after that must be
recreated. So the conviction moves from a fixed 4-probe count (~10s) to
their 30s bound, probing every 2.5s. Our own 20 samples saw nothing land
between 3s and 180s, but their number is authoritative for their system,
and the grace runs concurrently with acquire setup, so a healthy sandbox
still pays nothing.

Claude-Session: https://claude.ai/code/session_014s6jqKCsVKsNMmnrJVJkZt
Product-owner call (2026-08-31): every healthy sandbox in the 20-sample
measurement answered on its FIRST probe, Daytona's wiring fix is in
progress, and a 30s grace only holds a stuck user's turn for a recovery
nobody has observed. The module doc records the decision and the signal
that would reopen it (a 'substitution confirmed after N probes' log with
N > 1 at a late offset).

Claude-Session: https://claude.ai/code/session_014s6jqKCsVKsNMmnrJVJkZt
@mmabrouk
mmabrouk force-pushed the feat/lifecycle-diagnostics branch from 80ae98f to 1413a5f Compare August 31, 2026 15:44
mmabrouk added a commit that referenced this pull request Aug 31, 2026
Daytona's egress proxy scrubs responses, rewriting a real credential
back into `dtn_secret_<id>`. A bare `dtn_` in the probe body was
therefore not evidence: an endpoint that echoes the Authorization header
returns that shape on a perfectly healthy sandbox, so both acquire
attempts were destroyed and the first turn failed even though the real
model call would have worked.

Convict only on a masked echo, which scrubbing cannot forge because the
masked string no longer holds the real value to match. That covers both
proven shapes (LiteLLM's `Received=dtn_****`, OpenAI's
`dtn_secr*****`) and every incident observed so far, so the narrower
signature costs no detection. An unmasked echo now logs and fails open.

Review comment from @mmabrouk on #6370.

Claude-Session: https://claude.ai/code/session_0165tsjmvf3qvTPFcb9EV44g
@mmabrouk
mmabrouk force-pushed the feat/credential-preflight branch from 89e1508 to a8bfc83 Compare August 31, 2026 15:44
Daytona's egress proxy scrubs responses, rewriting a real credential
back into `dtn_secret_<id>`. A bare `dtn_` in the probe body was
therefore not evidence: an endpoint that echoes the Authorization header
returns that shape on a perfectly healthy sandbox, so both acquire
attempts were destroyed and the first turn failed even though the real
model call would have worked.

Convict only on a masked echo, which scrubbing cannot forge because the
masked string no longer holds the real value to match. That covers both
proven shapes (LiteLLM's `Received=dtn_****`, OpenAI's
`dtn_secr*****`) and every incident observed so far, so the narrower
signature costs no detection. An unmasked echo now logs and fails open.

Review comment from @mmabrouk on #6370.

Claude-Session: https://claude.ai/code/session_0165tsjmvf3qvTPFcb9EV44g
@mmabrouk
mmabrouk force-pushed the feat/credential-preflight branch from a8bfc83 to 4187659 Compare August 31, 2026 15:49
mmabrouk added a commit that referenced this pull request Aug 31, 2026
Vault-backed fixture connections (the #6371 review catch), and the
stuck-substitution rebuild that can fake an eviction in a sandbox-counting
warm case after #6370.

Claude-Session: https://claude.ai/code/session_0165tsjmvf3qvTPFcb9EV44g
@mmabrouk
mmabrouk changed the base branch from feat/lifecycle-diagnostics to release/v0.114.4 August 31, 2026 16:36
@mmabrouk
mmabrouk merged commit 35bfacf into release/v0.114.4 Aug 31, 2026
44 of 45 checks passed
@mmabrouk mmabrouk mentioned this pull request Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend size:L This PR changes 100-499 lines, ignoring generated files. tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant