feat(workspace): validate /goal objective length in composer#4337
Conversation
267e297 to
3f168bb
Compare
willem-bd
left a comment
There was a problem hiding this comment.
Findings from review (1 inline comment). Summary: the goal-length guard is sound for the common case, but it measures the normalized objective length while the backend's binding rejection is pydantic max_length=4000 on the raw objective (sent raw, interior whitespace preserved). Net effect: whitespace-padded inputs pass the client guard with a green counter, then 422 on the backend - the exact raw error this PR aims to eliminate. Recommend mirroring the raw-length constraint (and dropping the client-side normalization, which the backend does not apply before its length check).
| } | ||
|
|
||
| export function isGoalObjectiveTooLong(objective: string): boolean { | ||
| return normalizeGoalObjective(objective).length > MAX_GOAL_OBJECTIVE_CHARS; |
There was a problem hiding this comment.
[Correctness] Guard checks normalized length, but the backend rejects on raw length - the guard leaks a 422 for whitespace-padded inputs
This isGoalObjectiveTooLong measures normalizeGoalObjective(objective).length, and getGoalObjectiveCounter does the same. But the backend's binding rejection is on the raw objective length, not the normalized one:
ThreadGoalRequest.objective: str = Field(..., max_length=4000)(backend/app/gateway/routers/threads.py:323) - pydantic validates the raw string length and returns 422 before the endpoint body runs.normalize_goal_objective(backend/packages/harness/deerflow/runtime/goal.py:108) re-checks the normalized length, but normalization only ever shrinks the string, so that second check is redundant - the binding constraint is the pydanticmax_lengthon the raw field.
And the frontend sends the objective raw (input-box.tsx: JSON.stringify({ objective: command.objective })), where parseGoalCommand trims leading/trailing whitespace but preserves interior whitespace.
Repro: /goal + "a" x2000 + 4 spaces + "a" x1999
command.objective= 4003 raw chars (interior spaces preserved)- Client guard: normalized = 4000 ->
4000 > 4000= false -> no toast, PUT sent - Counter shows
4000/4000(green, non-destructive) -> user thinks they're within the limit - Backend: pydantic
max_length=4000on the 4003-char raw string -> 422 - the exact raw error this PR aims to eliminate
This is realistic on paste (multi-line goals with blank lines / indentation, or runs of spaces between words). The PR's own test "counts collapsed whitespace like the backend, avoiding false rejections" (the interiorAtLimit case) asserts this allowance as correct - but the backend would 422 that input, so the test encodes the bug.
Fix: mirror the backend's actual binding constraint - check command.objective.length > MAX_GOAL_OBJECTIVE_CHARS (already leading/trailing-trimmed by parseGoalCommand) and have the counter display command.objective.length. The normalizeGoalObjective mirroring should be dropped here: the backend does not apply it before the pydantic check, so applying it client-side is what opens the hole. (If normalization is kept, then the objective must be normalized before the PUT body too, and the guard/counter measure the normalized value - but that double-normalizes against the backend's own normalize_goal_objective, so the raw-length check is cleaner.)
The guard and the counter need to use the same measure the backend enforces, or the green counter actively misleads the user into submitting inputs that 422. Note: MAX_GOAL_OBJECTIVE_CHARS = 4000 itself matches the backend (runtime/goal.py:37), so it's only the normalization-before-check that's off.
The composer sent `/goal <objective>` of any length, so an objective past the backend limit (`MAX_GOAL_OBJECTIVE_CHARS = 4000`) only failed after a round-trip with a raw HTTP 422. Mirror the limit client-side: reject an over-length objective before the PUT with a friendly toast, and reject the submit so PromptInput keeps the user's text for editing instead of clearing it. Add a live footer counter that surfaces near the limit and turns destructive when exceeded. Length is measured with the same whitespace normalization the backend applies, so the counts match exactly.
3f168bb to
96706b6
Compare
Brings in: - fix(feishu): check response.success() on send_file's reply/create calls (bytedance#4335) - feat(workspace): validate /goal objective length in composer (bytedance#4337) - fix(workspace-changes): count diff body lines starting with '-- '/'++ ' (bytedance#4303) No file overlap with the memory interface work; clean merge (ort strategy). Co-Authored-By: Claude <[email protected]>
…UI hardening - bytedance#4117 XSS hardening (isSafeHref, iframe sandbox, safeLocalStorage) - bytedance#4354 streaming chunk batcher for large file tools - bytedance#4294 LLM concurrency cap + burst-rate retry shedding - bytedance#4355 E2B release serialization - bytedance#4267 transient image context cleanup - bytedance#4374 AI disclaimer (bytedance#4373 reasoning-effort default) - bytedance#4337 /goal length validation + counter - bytedance#4278/bytedance#4302/bytedance#4312 URL encoding fixes - bytedance#4375 list_uploaded_files schema fix - bytedance#4288 uploads markdown companion name claim - bytedance#4258 remove frontend debug logs
Why
The composer sent
/goal <objective>of any length. The backend rejectsobjectives longer than
MAX_GOAL_OBJECTIVE_CHARS = 4000(
ThreadGoalRequest.objective/normalize_goal_objective), so anover-length goal only failed after a network round-trip and surfaced as a
raw HTTP 422. Users also had no way to see they were approaching the limit
while typing.
How
(
" ".join(objective.split())) client-side ininput-box-helpers.ts, sothe character count matches exactly what the backend measures and avoids
false rejections caused only by redundant whitespace.
friendly toast. The submit is rejected (like the streaming guard) so
PromptInputpreserves the user's text for editing instead of clearing it.getGoalObjectiveCounter) that stays hiddenuntil the objective approaches the limit (90%), then shows
length/max,turning destructive once the limit is exceeded.
counter's visibility/over-limit states.