lester076 9d0509925e
fix(sandbox): reject write_file payloads that are the elision placeholder (#7374)
* fix(sandbox): reject write_file payloads that are the elision placeholder

The tool-output budget middleware replaces a superseded write_file payload
with a deterministic placeholder in the model-bound request only. That
placeholder is text the model sees in its own history, so it can imitate it
and submit it back as real write_file content: #7319 reports a delivered
file that was 194 bytes of placeholder.

write_file now rejects a payload that is the placeholder (whole payload,
allowing only surrounding whitespace) with an actionable error telling the
model to read_file the target path and write the real content; nothing is
written. The match is whole-payload on purpose: a substring guard would
block legitimate edits to this repository, whose constants.py and
persistence docs quote the template verbatim.

The placeholder wording moves to one shared constant,
WRITE_ELISION_PLACEHOLDER_TEMPLATE in deerflow.constants, which the
middleware renders from and the guard matches on, so the two cannot drift.

Regression tests fail on base (7 failed, 6 passed) and pass with the fix;
near-miss payloads that merely quote the placeholder still write.

Refs #7319

* fix(sandbox): match complete write elision placeholders

* docs: clarify write placeholder validation scope

* chore: re-trigger CLA status check

* fix(sandbox): preflight async writes and trim guidance

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-25 21:15:14 +08:00

82 lines
4.7 KiB
Python

"""Shared runtime protocol constants."""
DEFAULT_SKILLS_CONTAINER_PATH = "/mnt/skills"
# Framework-owned placeholder the tool-output budget middleware substitutes for
# an elided ``write_file`` payload in the model-bound request (issue #5328), and
# which ``write_file`` refuses to write back out (issue #7319). The model
# occasionally imitates its own elided history and submits the placeholder as
# content, leaving a ~200-byte deliverable; a rejection the model can act on is
# better than a silent empty file. Rendering and rejection both derive from this
# one string so the wording cannot drift out of sync with the guard.
WRITE_ELISION_PLACEHOLDER_TEMPLATE = "[content elided: {chars} chars; this write_file call succeeded and the file was read or modified again afterwards, so the on-disk file is the current version; call read_file on its path to see it]"
# Host-only per-run capability. Keep this dependency-free: the runtime worker
# must not initialize the tool/subagent packages while importing this key.
CONVERSATION_READER_CONTEXT_KEY = "__conversation_reader"
CONVERSATION_TOOL_USE = "deerflow.tools.conversation:read_conversation"
# The Gateway sizes reader pages by this tool's tool-output budget entry.
CONVERSATION_TOOL_NAME = "read_conversation"
# Hidden subdirectory (under a thread's outputs dir) that holds the browser
# tools' per-step screenshots. These are transient live-progress frames, not
# deliverables, so the workspace-changes scanner excludes this directory. Both
# the browser tools (which write here) and the scanner (which ignores it) import
# this single source of truth so the name cannot drift between them.
BROWSER_FRAMES_DIRNAME = ".browser-frames"
# Default subdirectory (under a thread's outputs dir) where the tool-output
# budget middleware persists oversized tool outputs. These are process
# feedback the model reads back via ``read_file`` (the budget preview carries
# the reference), not deliverables, so the workspace-changes scanner excludes
# this directory and run delivery verification never counts it as a produced
# artifact. Both the budget middleware's default ``storage_subdir`` and the
# scanner import this single source of truth so the name cannot drift between
# them; a custom configured ``storage_subdir`` is threaded through the
# snapshot capture as an extra excluded dir name.
TOOL_RESULTS_DIRNAME = ".tool-results"
# Hidden directory under a thread workspace owned by stdio MCP runtimes. The
# default subprocess temp directory lives at ``.mcp/tmp``; these files are
# process-internal state rather than workspace deliverables, so the
# workspace-changes scanner excludes the whole reserved namespace. MCP launch
# paths and the scanner share this name so writes and filtering cannot drift.
MCP_INTERNAL_DIRNAME = ".mcp"
# Default subprocess temp subdirectory pinned into stdio MCP environments
# (``TMPDIR``/``TMP``/``TEMP``). Both stdio launch paths (persistent sessions
# and background task calls) import this instead of composing the suffix
# themselves. Pinning the process temp dir here (alongside its cwd) makes
# tools that write to ``os.tmpdir()`` / ``tempfile.gettempdir()`` land inside
# the mounted user-data tree, where their output is resolvable by the
# sandbox/artifact API — instead of on an unreachable host temp path.
MCP_TMP_SUBDIR = f"{MCP_INTERNAL_DIRNAME}/tmp"
# Default timeout (seconds) for MCP server bring-up: tool discovery (subprocess
# spawn + initialize + tools/list) and persistent-session initialization. A hung
# stdio server (e.g. npx blocked on a package download or a server that never
# answers initialize) would otherwise block agent construction forever — and on
# the Gateway event loop, the whole process. Per-server override is
# ``mcpServers.<name>.session_init_timeout``; ``None`` disables the timeout.
DEFAULT_MCP_SESSION_INIT_TIMEOUT = 60.0
# Durable MCP task storage/protocol limits. The runtime validators and ORM use
# the same constants so SQLite cannot accept values that PostgreSQL later
# rejects at its VARCHAR boundaries.
MCP_TASK_SERVER_NAME_MAX_LENGTH = 128
MCP_TASK_REMOTE_ID_MAX_LENGTH = 255
MCP_TASK_NAME_MAX_LENGTH = 255
MCP_TASK_RESULT_ARTIFACT_MAX_BYTES = 65_536
MCP_TASK_POLL_AFTER_MAX_SECONDS = 86_400
# Persisted run-event envelope limits. Runtime definitions and the ORM both
# import these from this dependency-free module so lower layers never need to
# initialize deerflow.runtime just to validate storage constraints.
RUN_EVENT_TYPE_MAX_LENGTH = 32
RUN_EVENT_CATEGORY_MAX_LENGTH = 16
# Workspace changes are produced below the runtime layer, so their persisted
# event identity also lives here rather than in the runtime event catalog.
WORKSPACE_CHANGES_EVENT_TYPE = "workspace_changes"
WORKSPACE_CHANGES_EVENT_CATEGORY = "workspace"