3 Commits

Author SHA1 Message Date
Storm
b9a75b869d
fix(sandbox): attest lark broker mode per Pod and quarantine uncertain shell outcomes (#6436)
* fix(sandbox): attest lark broker mode per Pod and quarantine uncertain shell outcomes

Why this change:
Two fail-open paths in the AIO sandbox lifecycle. First, the lark broker
capability probe collapsed "provisioner unreachable" into "broker not
configured": one 1.5s timeout cached a negative for 300s, so new sandboxes
were created without the broker sidecar while the per-user credential
directories were still bind-mounted into the container, and existing Pods
kept being reused without their actual mode ever being read. Second, an
implicit-shell command that timed out or returned an unknown status only
marked the current client object; warm-pool reclaim and rediscovery built a
fresh client that re-entered a container whose shell state could not be
proven clean, and nothing about that ambiguity survived a Gateway restart.

What changed:
- The capability cache stores confirmed observations only, keyed per
  provisioner endpoint/auth identity; an unknown answer (timeout, malformed
  payload) raises instead of selecting credential placement, while a
  confirmed broker requirement is retained through outages. One in-flight
  probe per identity shares success and failure, and a Pod-attested mode
  contradicting the cache forces one shared refresh.
- Execution uses the mode attested for the actual sandbox:
  SandboxInfo.lark_cli_broker flows from the provisioner's atomic Pod
  observation into the client, so a probe outage delays drift detection but
  never denies reuse of an attested runtime, and never trusts an unattested
  one.
- The provisioner reports each Pod's actual broker mode, generation (Pod
  UID) and shell capacity from one read, rejects lark requests conflicting
  with its configuration (409/503 carrying a capability-refresh marker the
  Gateway uses to drop the stale cache entry), rejects conflicting or
  Terminating Pods on coordination creates while reporting Terminating as
  data to status/get/list, and answers Pod presence independently of
  Service readiness.
- A persistent quarantine (DEER_FLOW_HOME/sandbox-quarantine, keyed by
  scope/sandbox/generation) fences containers after uncertain implicit
  shell outcomes across client rebuilds and restarts; in-process reuse,
  warm reclaim, discovery, create responses and orphan reconciliation all
  pass one policy gate, and replacement runs under the existing ownership
  and teardown fences. A fenced sandbox still tracked after a failed
  release-recycle is destroyed inline on the next acquire.
- Quarantine records retire only after confirmed absence, retrying partial
  cleanup for the quarantined generation and releasing remembered ports
  under the same fences; local stop failures now propagate instead of being
  logged away, and remote destroy errors surface to callers.

Problem solved:
A provisioner blip could silently strip broker isolation from new lark
sandboxes for minutes at a time, and ambiguous shell outcomes let corrupted
default shells persist across warm reuse, rediscovery and restarts. Docs
updated: README, aio_sandbox and skills AGENTS.md.

* docs(changelog): record sandbox attestation and quarantine fix (#6436)

* fix(sandbox): preserve active leases and fail closed on unattested broker modes

Concurrent uploads could recycle a healthy AIO runtime while another holder
was creating or cleaning up a command session. Older provisioner responses
could also trigger destructive replacement before failing admission.

- Distinguish pending session creation from ambiguous outcomes and reserve
  idle teardown until execution and upload holders finish scope cleanup.
- Reject stale fork lookups and reconcile cancelled acquires without
  releasing a sandbox still borrowed by another holder.
- Default Lark broker mode to unverified and preserve unattested Pods until
  the provisioner is upgraded.
- Recognize Docker 29 absence diagnostics while matching resource names exactly.
- Preserve Gateway upload errors and cover admission and lifecycle regressions.

* fix(sandbox): scope the unverified-broker refuse path to attested providers

The fail-closed raise in _lark_cli_env_from_runtime treated every Sandbox
without a lark_cli_broker attribute as unverified, but only AioSandbox
carries the attestation — E2B, OpenSandbox, Tenki, and Boxlite have no
broker sidecar, no credential mounts, and no provisioner to upgrade, so
every lark-cli command on those providers failed with an unactionable
error.

Declare the tri-state on the Sandbox base class (defaulting to False,
the only mode brokerless providers have) so the None fail-closed path
stays scoped to AioSandbox's deliberate unverified default.

- Add lark_cli_broker: bool | None = False to the Sandbox ABC, mirroring
  the persistent_shell_sessions tri-state pattern
- Add a regression test pinning the non-broker overlay for a provider
  without the attribute

* fix(sandbox): close review gaps in broker attestation and teardown lifecycle

Address the latest #6436 review round:

- The provisioner's post-build broker-mode 409 now carries the
  capability-refresh marker, so an incompatible-mode conflict surfaced by
  idempotent re-create, attestation drift or post-create verification drops
  the Gateway's cached capability observation instead of repeating the same
  409 for the full negative-cache TTL.
- AioSandboxProvider.destroy() returns the _destroy_tracked outcome: False
  when teardown is deferred because an execution/upload holder or a peer
  teardown is still active, so callers can no longer mistake a deferred
  destroy for a completed one.
- A live replacement under the same logical ID proves the fenced old
  generations are absent; retire_replaced_generations prunes only those
  stale records so they cannot strand future creates, while the live
  generation's fence and unversioned crash fences are never touched.
- SandboxInfo.from_dict keeps a missing lark_cli_broker attestation
  unverified (None) instead of silently rehydrating as attested non-broker.
- The restricted-network inspect path shares the strict
  _is_no_such_network_error parser, closing the substring loophole for
  context-name collisions and wrong-target errors.

* docs(sandbox): condense lease notes to keep the AGENTS.md chain under the size gate

The three lease notes added in 26c307f3 pushed the effective guidance chain
to 98,312 bytes — 8 over the 98,304 hard limit enforced by agent-guidance
(AG002). Fold them into two lines carrying the same rules.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-10 07:56:09 +08:00
Aari
9e0fbd60fa
fix(sandbox): isolate concurrent subagent shell sessions (#5134)
* fix(sandbox): isolate concurrent subagent shell sessions

* fix(sandbox): make execution acquire idempotent

* fix(sandbox): close execution lifecycle gaps

* fix(sandbox): serialize retained client lifecycle

* fix(sandbox): close remaining client lifecycle gaps

* fix(sandbox): unwind failed client lookup

* fix(sandbox): protect internal lease identities

* fix(sandbox): make cancellation reconciliation durable

* fix(sandbox): fence cancelled workers and IM uploads
2026-09-02 21:05:23 +08:00
Fgoll
62b73fd2ea
feat(dingtalk): support inbound file and image attachments (#4423)
* feat(dingtalk): support inbound file and image attachments

DingTalk previously dropped picture and file (document) messages because
`_on_chatbot_message` ignored any message with empty text, so users could
not send files to the agent. This adds inbound attachment support, mirroring
`FeishuChannel`:

- `_extract_files` parses `picture`/`richText` image downloadCodes and `file`
  (document) descriptors. `dingtalk_stream.ChatbotMessage.from_dict` does not
  parse `file` messages, so `_DingTalkMessageHandler.process` stashes the raw
  callback payload on the message (`_df_raw_data`) for the document descriptor.
- `receive_file` downloads each attachment by `downloadCode` via the robot
  `messageFiles/download` OpenAPI, persists it into the thread uploads bucket,
  syncs it into a non-local sandbox, and prepends the sandbox virtual path to
  the message text so the agent can read the file by path.
- Filenames go through the shared `uploads.normalize_filename` helper, which
  strips directory components and rejects traversal patterns.

Outbound `send_file` already existed; this completes DingTalk file parity with
Feishu on the inbound side. Adds 21 tests covering extraction, download-by-code,
persistence/sandbox sync, filename sanitization, and the handler raw-data stash.

* fix(dingtalk): address inbound-file review feedback

Follow-up to the review on #4423:

- Make the fallback filename safe by construction. `download_code` is
  attacker-controllable webhook data and was embedded into `fallback_name`
  unsanitized; it only avoided escaping the uploads directory because the
  resulting write failed with OSError. It is now restricted to
  `[A-Za-z0-9_-]` before use. Covered by a test that reproduces the old
  behaviour (`uploads/dingtalk_../../evil.png`) and by a test that actually
  exercises the previously untested `except ValueError` branch (`".."`,
  whose basename — unlike `../../etc/passwd` — does raise).
- Log the swallowed `get_image_list()` failure instead of silently returning
  no images, so an SDK parse failure is distinguishable from a richText
  message that genuinely has no inline images.
- Surface failed downloads to the agent as a short `[failed to load ...]`
  marker rather than silently omitting the attachment, so a user whose file
  did not load does not simply appear to be ignored. Keeps the cleaner text
  shape while restoring the signal Feishu provides.

Tests: 119 passed (was 115).

* fix(dingtalk): claim unique upload names and refuse symlinked destinations

Round 2 review follow-up on #4423. Both findings reproduce as failing tests
against the previous head.

- Inbound attachments no longer overwrite each other. Generated names repeat
  across messages (every picture message yields "image.png", richText yields
  "image_0.png"), so a later attachment silently replaced an earlier one whose
  virtual path had already been prepended to the message text — the agent could
  read bytes that were not the ones its prompt referenced. The destination name
  is now claimed with the shared `claim_unique_filename` against the live
  directory contents, which also covers a real filename sent twice
  (`quote.xlsx`), a case Feishu's inline naming does not handle either. The
  claim and the write happen under one lock so two attachments cannot resolve
  to the same free name.
- Writes go through the shared `write_upload_file_no_symlink` instead of
  `Path.write_bytes`. Uploads dirs may be mounted into local sandboxes, so a
  sandbox process could leave a symlink at a future upload name and redirect a
  gateway-privileged write outside the bucket; the regression test shows the
  old code creating the out-of-bucket target.

Tests: 123 passed (was 119).

* fix(dingtalk): harden the inbound download path (self-audit)

Proactive hardening pass over the new inbound path; each fix reproduces as a
failing test against the previous head.

- Contain token failures. `_get_access_token()` sat outside the try in
  `_download_by_code`, and the manager awaits `receive_file` without one — a
  DingTalk auth hiccup during a file message aborted the whole chat turn with
  no reply. Token acquisition moves inside the try, and `receive_file` gains
  per-attachment isolation so no unforeseen error can escape past the marker.
- Cap inbound size. The download buffered arbitrary bytes in memory
  (`response.content`) with no limit, while outbound uploads already enforce
  one. The body is now streamed and dropped once it exceeds
  `_MAX_INBOUND_FILE_SIZE_BYTES` (50 MB), surfacing as a failed-load marker.
- Sanitize the failure marker. It embedded the raw webhook `fileName`; a
  newline could forge a standalone `/mnt/user-data/uploads/...` line inside
  msg.text and an over-long name bloated it. Markers now collapse whitespace
  and cap at 80 chars.
- Keep blocking IO off the event loop. `ensure_thread_dirs`, the uploads-dir
  resolve, sync `SandboxProvider.acquire`, and `sandbox.update_file` all ran on
  the loop; directory prep now lives inside the same `asyncio.to_thread` as the
  claim+write, and sandbox sync uses `acquire_async` + an offloaded
  `update_file`. Locked by a strict Blockbuster anchor
  (tests/blocking_io/test_dingtalk_receive_file.py), verified to fail with
  `BlockingError: Blocking call to os.mkdir` when the offload is reverted.

Tests: 127 + 1 blocking-io anchor (was 123); tests/blocking_io/ suite 55 passed.

* fix(dingtalk): surface missing-sandbox sync as a failed load

Round 3 follow-up on #4423:

- When a non-local sandbox acquire succeeds but the provider cannot resolve
  the instance, _receive_single_file returned the virtual path anyway — a
  path the agent's sandbox cannot read. Mirror Feishu: log and return "",
  so the [failed to load ...] marker fires instead. Red-first test:
  test_missing_sandbox_after_acquire_yields_marker.
- Drop the dead GetResponse / FakeClient.get scaffolding left in
  test_oversized_download_is_dropped from its red-first iteration.

Tests: 128 + 1 blocking-io anchor (was 127 + 1).

* fix(dingtalk): treat non-local sandbox sync failure as a failed load

Round 4 follow-up on #4423. The sync except-branch logged and still returned
the virtual path when acquire or update_file raised on a non-local sandbox —
the same handing-the-agent-an-unreadable-path failure mode the sandbox-is-None
branch was just fixed for, and exactly the leg the suite did not exercise.
Feishu's except-branch returns its failure marker; DingTalk now does the
equivalent (return "" so the failed-load marker fires). Red-first test:
test_update_file_failure_yields_marker.

Tests: 129 + 1 blocking-io anchor (was 128 + 1).

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-27 14:19:39 +08:00