Skip to content

feat(web): cloud environments (part 2: setup scripts) - #424

Open
akhileshrangani4 wants to merge 16 commits into
cloud-env-setupfrom
cloud-env-part-2
Open

akhileshrangani4 wants to merge 16 commits into
cloud-env-setupfrom
cloud-env-part-2

Conversation

@akhileshrangani4

@akhileshrangani4 akhileshrangani4 commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Part 2 of cloud environments. Part 1 (#423) let you save a setup script. This one actually runs it.

Stacks on #423, so that one needs to go in first.

how it works

The script gets written into the sandbox as a real file at /home/daytona/.backgrounder/setup.sh, outside the repo clone so no push flow picks it up, and started as a detached job with @background-agents/sandbox-jobs. It isn't awaited. an npm install routinely outlives the request that kicked it off.

The chat goes into a new setting_up state and the first turn waits. GET /api/chats/[chatId]/setup streams the log and starts the turn when the job exits. If nobody's watching, the per-minute cron picks it up instead. Both of them can see the same exit, so the transition is a status-guarded updateMany and only whoever actually changed a row starts the turn.

If the script fails the turn still runs, the agent just gets told what broke. And since the script is a real file the agent can fix it, which syncs back to the environment with one level of undo. that also gives us "Set up with agent" basically for free, it's a normal chat pointed at that file with a seed prompt.

the file thing is the whole design

Writing it as a file instead of piping a string is what makes the agent able to fix or author a script with no new tool to learn or forget. Everything else falls out of that.

Sync-back only ever reads the script back. never variables, never network mode, never the name. an agent that can write a file shouldn't be able to write a secret into the DB, and the query doesn't even load the variables.

stuff worth knowing before you review

scheduled runs skip setup on purpose. they'd have started a job nobody waits for, so the agent would run against a half-installed tree. gave createSandboxForChat an opt-out and the cron passes it, so scheduled behaviour is identical to today. proper fix needs the same waiting machinery the chat path has, didn't want to write a second copy of it.

Run setup uses a throwaway sandbox with a 3 minute script cap (under the route's 5 minute ceiling) and a 15 minute auto-delete, so a hard-killed invocation leaks minutes instead of the default 4 days.

that route is a GET that creates a sandbox. EventSource can't send a body. it checks Sec-Fetch-Site: same-origin so a crafted link can't spend your quota, which is defence in depth and not a CSRF token. real fix is POST-to-start plus GET-to-stream, didn't do it here. also Safari before 16.4 doesn't send that header so those users get a 403 on that button.

env var names are validated now (^[A-Za-z_][A-Za-z0-9_]*$). sandbox-jobs interpolates the key straight into an export line and nothing was checking it. not an escalation since it's your own vars in your own sandbox, but an invalid name used to fail as a weird shell error instead of a clear 400.

not verified

i haven't tested this against a real sandbox yet. still need to run it with a Daytona API key. so the server emitting a held response off a live job, the real SSE stream, and a real dispatch on job exit are all unproven so far. the client seam is verified with intercepted routes, that's it.

There's a 7 item staging checklist in the spec covering what I still need to run. The one I'd actually worry about is whether the cron's per-minute poll counts as Daytona activity against autoStopInterval: 5. if it doesn't, a long script with nobody watching gets its sandbox stopped out from under it. two code comments used to assert it did, they now say it's an assumption.

Also e2e/setup-scripts.spec.ts can't pass in our harness even with a key, the test-auth user has no linked GitHub account so the clone dies before any of this runs.

known gaps

  • after a revert, later agent edits hit the conflict branch and only log a warning. needs its own notification channel, left it out rather than bolt one on
  • no way to cancel a running setup, chat 409s until it finishes or times out
  • if the cron wins the claim with no client connected, the UI catches up on a chat switch or reload, not on its own

testing

556 unit tests, typecheck clean, production build clean.

…cap test

Reject environment variable names outside the POSIX name rule
(^[A-Za-z_][A-Za-z0-9_]*$) at the API boundary (PATCH /api/environments/[id]
and PATCH /api/user/repo-env), and make encryptEnvironmentVariables skip
invalid keys as a second line of defense. Job env keys reach a shell export
line unquoted, so an invalid key was a metacharacter injection point, not
just a validation gap.

Also add a multi-byte case to the setup-script size-cap test: an all-ASCII
oversized string can't tell a correct Buffer.byteLength check apart from a
wrong .length check, so it didn't actually pin the byte-cap behavior.

Minor: quote the hardcoded paths in writeSetupScript's shell commands to
match the uploadFilesToSandbox precedent.
…ccess

handleSaveEnvVars awaited both PATCHes without checking response.ok, and
EnvironmentVariablesModal treated a non-throwing onSave as success and
closed. Once /api/user/repo-env started rejecting invalid variable names,
that gap went from dormant to live: a bad key closed the modal as though
the save worked, with nothing actually saved.

patchJsonOrThrow now throws with the server's error message on a non-2xx
response, and runSaveEnvVars keeps the modal open and shows that message
on a rejected save, matching how the standalone Environment editor already
handles this.

Also close the third route the same key validation missed: PATCH
/api/chats/[chatId]/env now validates keys against the same POSIX rule
before encrypting, with accept/reject tests.
createSandboxForChat now writes the environment's setup script into every
new sandbox and starts it as a detached job when the script is non-empty.
The resulting SetupRunRecord is persisted on the chat, and ensure-sandbox
moves the chat to setting_up only while a job is genuinely running.

SetupRunRecord.handle is now optional: a script-less environment gets a
record for hash tracking but no fabricated JobHandle, since there is no
job to poll or attach to. isSetupRunRecord enforces that a handle-less
record can never claim state "running".
…cing setup

createSandboxForChat now wraps everything after daytona.create in a
try/catch: a throw anywhere in that span (clone, branch setup, writing or
starting the setup script) deletes the sandbox it just created and
rethrows the original error unchanged, so a failed create no longer
leaves an untracked sandbox behind.

Added a runSetupScript option, defaulting to true, and scheduled.ts now
passes false. The cron has no setting_up-equivalent gating yet, so
starting a setup job there would let the agent run against a tree that
is mid-install; scheduled runs keep getting the environment's variables,
just not its setup script, until that follow-up lands.

Also drops a stray em dash from a setup-script.test.ts comment.
A chat whose sandbox started a setup job now returns setting_up instead of
running the turn. The user's message is persisted before that return, so a
client that disconnects does not lose it and the cron backstop has something to
dispatch.

The turn-start path moves out of the messages POST handler into
runQueuedTurnForChat, so exactly one path starts an agent turn: the handler
drives it for a normal send, and dispatchQueuedTurn drives it for a turn held by
setup. The move is verbatim apart from setupFailureNote, which is prepended only
to the string handed to the agent, never to the persisted user message.

Both the /setup SSE endpoint and the agent-lifecycle cron can observe the same
job exit, so the setting_up transition is an updateMany guarded on the current
status and only the caller that changed a row starts the turn. Setup failure
never hard-blocks: the agent runs anyway, told what broke and where the script
lives.
Claiming a finished setup run used to flip the chat to ready before starting
the turn. Nothing looks at a ready chat: the cron's interactive monitor wants
running plus a backgroundSessionId, its setup phase wants setting_up, and
/api/agent/stop returns early without a backgroundSessionId. An invocation
killed during turn startup, which spans history, MCP, skill discovery and
session creation, therefore stranded the chat with a persisted message, no
reply and no way out.

The claim now only stamps claimedAt into setupRun and leaves the chat in
setting_up, so it stays 409-busy to a second tab, persistTurn moves it to
running when the turn really starts, and the cron re-claims a claim older than
five minutes. Verified against Postgres: an unclaimed run is claimable, a live
claim is not, a stale one is, and two concurrent observers still produce one
winner.

Also: the dispatcher and cron phase 5 start the sandbox before using it, since
a held turn skipped ensureSandboxForChat and autoStopInterval is five minutes;
phase 5 marks a chat errored when its sandbox is gone instead of re-failing
forever; the agent-switch replay excludes the queued message, which the earlier
persist made visible to it; and the no-handle case leaves the SSE endpoint as a
done event rather than a JSON body an EventSource client cannot read.
The setup dispatch phase marked a chat errored on any throw from daytona.get,
not just a 404. Because the phase iterates every setting_up chat, a Daytona
5xx, a network timeout, or a rotated API key would have errored all of them in
one tick and discarded every queued turn in the system.

isSandboxGoneError, alongside the existing statusCode precedent in
lib/sandbox.ts, now separates the two. Only a genuinely absent sandbox is
terminal; everything else rethrows into the per-chat catch, so the chat stays
setting_up and the next tick retries. That matches what the adjacent
ensureSandboxStarted call already did.

Phase 5 moves into _lib/setup-dispatch.ts, following the convention the route
already documents and its four other phases already follow, so both give-up
branches can be tested directly. Removing the guard fails exactly the two
transient-failure tests.
Adds syncSetupScript, called best-effort after both turn-completion paths
(the SSE stream handler and the agent-lifecycle cron), so a fix an agent
makes to a broken setup script persists for future chats on that repo.
Only the script column is ever written back; a diverged stored script
(a user edit during the turn) is left alone rather than overwritten.
Adds SetupBlock (live progress off the setup SSE stream, collapses on
success, expands with the log on failure or a broken stream) and
SetupScriptUpdatedNotice (diff view + one-level revert) to ChatPanel,
plus the revert-script endpoint they need. Extends EnvironmentDTO with
setupScriptPrevious, which nothing previously exposed to the client.
…estamps

The notice's visibility was gated on Environment.updatedAt vs Chat.updatedAt,
but sync-back always writes the environment before the chat's own row is
bumped, so the comparison had the ordering backwards and could never fire.
Replace it with an explicit scriptUpdateNotice marker that sync-setup-script
stamps onto the chat itself and that rides the SSE complete event straight to
a watching client, with no query invalidation required. The notice now fetches
its diff bodies on demand instead of holding them for every chat, and can be
dismissed (keyed per edit, so a later edit is never swallowed by an earlier
dismissal).

Also stop SetupBlock from reporting a broken SSE connection as a failed
script: reconnect with a bounded backoff first, and only give up with an
honest "connection lost, setup may still be running" once retries are
exhausted.
Adds two actions to the environment editor: "Run setup" validates the
saved script in a throwaway sandbox built from the environment's real
settings (repo, network mode, variables), streaming its output and
deleting the sandbox on every exit path. "Set up with agent" opens a
normal chat seeded to have the agent write, run, and iterate on the
script until the project builds, then navigates there so the chat is
never created somewhere the user can't find it.

Extracts SetupBlock's log-panel markup into a shared SetupLogPanel so
the new run doesn't duplicate that rendering. Splits the pure setup
path/prompt helpers into lib/setup-paths.ts (re-exported from
lib/setup-script.ts) so the client-side prompt builder doesn't pull
Node's crypto or sandbox-jobs into the browser bundle.

Also corrects the setup-script description in the editor (it does run,
as of Task 3) and removes a leftover em dash.
…ranch

Caps a validation run's setup-script timeout well under the route's
maxDuration and gives the throwaway sandbox a short auto-delete window,
so a run that would previously outlive its own invocation no longer
strands a sandbox until Daytona's four-day backstop. Resolves the
repo's real default branch instead of assuming main. Requires
Sec-Fetch-Site: same-origin on the run-setup GET so a crafted
cross-site link cannot create and bill a sandbox on a signed-in
victim's account. Also reorders the assisted-setup prompt consumption
in ChatPanel to check agent/model before consuming the staged prompt,
so a partially-populated chat can't silently destroy it.
Both comments asserted that a jobs.status() poll counts as Daytona sandbox
activity as if it were confirmed fact. Nobody has run this against a real
sandbox yet, so reword both to say what is assumed versus what is verified,
and point at the staging checklist that covers it.
…e turns misfiring

The POST /messages held-turn body was a shape no client knew about: the
optimistic-send path read uploadedFiles off it, threw inside the cache
updater, and painted a red error on the first send of every chat whose
environment has a setup script. SendMessageResponse is now a discriminated
union with a setting_up variant, so the compiler makes every consumer say
which one it is handling. The held body carries sandboxId, branch and
previewUrlPattern so the cache is right before the turn starts, and drops
setupRun, which nothing read. The dispatcher parks the chat in setting_up,
skips startStreaming, and removes the optimistic assistant placeholder,
which the server never persists and whose id the dispatcher does not reuse.

When the setup stream finishes, the chat is reloaded with the server's own
status and session rather than being forced to ready, so the dispatched turn
is picked up immediately instead of after the next chat-list poll.

Also:
- the /setup route reads the job log from byte 0 on every connection, so the
  client replaces its buffer on each connection's first chunk instead of
  appending a full replay to it
- a queued turn is refused if the chat's newest conversational row is an
  assistant message: on a recreated sandbox a dispatch killed before the
  message was persisted could otherwise re-run an answered turn
- the /setup route starts the sandbox before polling it, as cron phase 5
  already does, so a tab reopened after autoStopInterval does not report a
  lost connection for a job that is merely parked
@vercel

vercel Bot commented Sep 8, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated
background-agents-docs Skipped Skipped Sep 8, 2026 6:21am UTC

Request Review

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