feat(authz): gate composer send on runs:create (Phase 4 follow-up, #4063) (#5528)

* feat(authz): gate composer send on runs:create (Phase 4 follow-up, #4063)

Mirrors #5294's stop gating on the send side: both chat routes pass
canCreateRuns (from PERMISSIONS.RUNS_CREATE, lockstep with the backend
enum) into the shared composer. The gate sits at the top of
submitThreadMessage — the single choke point every composer entry
(submit button, Enter, goal-set-triggered run) funnels through — and
denies with a toast plus a rejected promise so PromptInput keeps the
text. The idle submit button is disabled and explains the boundary via
conditionally-spread aria-label/title (startTurnUnavailable, en/zh);
while streaming the button stays the runs:cancel stop affordance.

Also removes the unreachable kind === "stop" branch in handleSubmit
(flagged during #5294's review): the Enter path early-returns with the
streaming toast before the classifier runs.

* fix(frontend): reject denied goal starts before saving state

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
hataa 2026-09-20 17:45:03 +08:00 committed by GitHub
parent 015ebcc88c
commit 71087f2f8e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 362 additions and 10 deletions

View File

@ -1459,6 +1459,8 @@ After each Gateway-backed run, DeerFlow evaluates the visible conversation again
The Web UI shows the active goal above the composer. The same command is available from the TUI and supported IM channels. In the Web UI and supported IM channels, setting `/goal <completion condition>` also starts a run with the condition as the task; status and clear commands only manage goal state. Setting or clearing a goal is rejected while that thread has a run in flight, including a run owned by another Gateway worker, so the goal checkpoint cannot branch away from an active run's checkpoint lineage.
When your role lacks `runs:create`, the Web UI rejects a new task or `/goal <completion condition>` before preparing the thread or saving the goal, and keeps your draft for retry. Goal status, goal clearing, and `/compact` remain governed by their own endpoint permissions.
### Manual Context Compaction
Optional `pii_redaction.enabled` redacts detected identifiers in user messages,

View File

@ -112,6 +112,8 @@ Auth UI note: the login page's "keep me signed in" option submits only `remember
`/goal` and `/compact` are built-in composer commands, not skill activations. `src/components/workspace/input-box.tsx` intercepts `/goal`, `/goal clear`, and `/goal <condition>` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal <condition>` also submits the condition text as the next user task so the agent starts running immediately; status and clear do not start a run. On a project-scoped new chat (`/workspace/chats/new?project=…`), the chat page's project pre-create runs before the goal PUT via the composer's `onPrepareThread` callback: the goal endpoint materializes a missing thread row itself, and an unassigned row would make the later idempotent thread create return it without assigning the project. Goal and compact requests are tied to the current `threadId` with an `AbortController`, so switching threads or unmounting the composer aborts in-flight requests and stale responses cannot update the new thread's composer state. The chat pages render `GoalStatus` above the composer from `AgentThreadState.goal`, with local optimistic state until an incremental goal update or final state reload arrives. `/compact` calls `POST /api/threads/{thread_id}/compact` to summarize older active context while leaving the full visible chat history intact; it is skipped on new/empty threads and blocked server-side while a run is in flight. Thread rename uses the same serialized state-write route; the rename dialog stays open and surfaces the server error when an active run returns 409.
Composer `runs:create` gating belongs in `InputBox.handleSubmit` after command classification and before dispatch. Reject message and goal-set actions before `onPrepareThread`, goal persistence, success feedback, or draft clearing. Goal status/clear and compact do not start runs; preserve their endpoint-specific gates. Regression coverage lives in `input-box-send-gating.dom.test.tsx`.
The `/` skill list stays reachable after a skill is selected: typing `/` in the editable text beside the chip reopens it, and picking an entry swaps the chip rather than adding a second one, because the wire format carries exactly one leading `/skill`. That list offers skills only while a chip is selected — a builtin command owns the whole composer line, so `/goal` behind a selected skill would submit as chat text instead of running the command. The trigger itself is unchanged: a slash only opens the list at the start of the input (`getLeadingSlashSkillQuery`), pinned by `tests/e2e/chat.spec.ts`.
Human input requests are a structured message protocol layered on normal chat history. The backend writes request payloads to `ToolMessage.artifact.human_input`, `src/core/messages/human-input.ts` owns the runtime validators/types, and `src/components/workspace/messages/human-input-card.tsx` renders the reusable card. The protocol is versioned on the request side only: v1 covers `free_text` / `choice_with_other`, and v2 adds `form` (typed fields — text/textarea/number/select/multi_select/checkbox/date — with required-field validation in the card). Replies deliberately stay on the v1 response protocol: the form card submits a `response_kind: "text"` reply whose value is the human-readable summary plus one JSON block keyed by stable field names (`buildHumanInputFormSubmissionValue` — the readable part alone is ambiguous because labels/values may contain the separators), so the model can reconstruct the submitted mapping without a structured response kind. The validators reject unknown versions/modes (and field names colliding with JS `Object.prototype` members) so future protocol bumps degrade to the plain-text ToolMessage fallback rather than rendering a broken card. Form values are read through own-property access only (`readHumanInputFormValue`); select fields stay controlled from their empty-string placeholder state through selection; checkbox fields are native `<input type="checkbox">` controls seeded to an explicit `false` (`buildInitialHumanInputFormValues`) so an untouched checkbox submits as "no" while a `required` checkbox keeps must-agree semantics (no HTML `required` attribute — native constraint validation would intercept the custom submit path), and form controls carry label/`htmlFor`, `aria-required` plus a visually-hidden localized "required" marker, and `aria-invalid`/error associations whose error node stays mounted while any field is still invalid. Composer-bypass closure: `deriveHumanInputThreadState` treats a visible plain human message as answering the latest unanswered request opened before it (only the latest — nothing guarantees a single outstanding request across runs, and closing all would silently swallow older decisions; an older request left open simply becomes the active card again). This lets current users bypass a structured form through the normal composer and preserves compatibility with old v1-only frontends that degrade a v2 request to plain text. `MessageList` owns answered/latest/pending state for visible cards, but derives answered responses from raw `thread.messages` because replies are hidden; pending cards clear when the hidden reply appears, when dispatch is dropped, or when a new `thread.error` reports an async stream failure. Page-level card submit callbacks must send a normal human message and put `hide_from_ui: true` plus the response payload in the fourth `sendMessage(..., options)` argument as `options.additionalKwargs`; the third argument remains run context such as `{ agent_name }`. Composer entry points remain enabled while a human-input request is open; a normal visible message intentionally bypasses the card and starts the next run without structured response metadata.

View File

@ -78,6 +78,7 @@ export default function AgentChatPage() {
const { t } = useI18n();
const { user } = useAuth();
const canStopStreaming = hasPermission(user, PERMISSIONS.RUNS_CANCEL);
const canCreateRuns = hasPermission(user, PERMISSIONS.RUNS_CREATE);
const router = useRouter();
const { agent_name } = useParams<{
@ -561,6 +562,7 @@ export default function AgentChatPage() {
onSubmit={handleSubmit}
onStop={handleStop}
canStopStreaming={canStopStreaming}
canCreateRuns={canCreateRuns}
/>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">

View File

@ -86,6 +86,7 @@ export default function ChatPage() {
const { t } = useI18n();
const { user } = useAuth();
const canStopStreaming = hasPermission(user, PERMISSIONS.RUNS_CANCEL);
const canCreateRuns = hasPermission(user, PERMISSIONS.RUNS_CREATE);
const router = useRouter();
const searchParams = useSearchParams();
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
@ -636,6 +637,7 @@ export default function ChatPage() {
onSubmit={handleSubmit}
onStop={handleStop}
canStopStreaming={canStopStreaming}
canCreateRuns={canCreateRuns}
/>
) : (
<div

View File

@ -311,6 +311,7 @@ export function InputBox({
onSubmit,
onStop,
canStopStreaming = true,
canCreateRuns = true,
agentSkillNames,
agentSkillsLoading = false,
...props
@ -385,6 +386,13 @@ export function InputBox({
* stays the enforcement point.
*/
canStopStreaming?: boolean;
/**
* Whether the caller's role holds `runs:create` (RFC #4063 Phase 4).
* Defaults to true so callers that don't resolve permissions (pre-Phase-4
* backends, storybook) keep today's behavior; the Gateway route guard
* stays the enforcement point.
*/
canCreateRuns?: boolean;
}) {
const { locale, t } = useI18n();
const queryClient = useQueryClient();
@ -1292,6 +1300,17 @@ export function InputBox({
messageWithSlashSkill.files.length + projectAttachments.length,
status,
});
// Check run-starting actions before goal preparation or persistence:
// saving a goal also clears the draft and announces success. Status,
// clear, and compact commands do not start runs and keep their own gates.
if (
!canCreateRuns &&
(submitAction.kind === "message" ||
(submitAction.kind === "goal" && submitAction.command.kind === "set"))
) {
toast.info(t.inputBox.startTurnUnavailable);
return Promise.reject(new Error("runs-create-denied"));
}
if (submitAction.kind === "goal") {
if (
submitAction.command.kind === "set" &&
@ -1354,10 +1373,6 @@ export function InputBox({
if (submitAction.kind === "compact") {
return handleCompactCommand();
}
if (submitAction.kind === "stop") {
handleStopStreaming();
return;
}
if (submitAction.kind === "empty") {
return;
}
@ -1368,9 +1383,9 @@ export function InputBox({
},
[
abortVoiceInput,
canCreateRuns,
handleCompactCommand,
handleGoalCommand,
handleStopStreaming,
onPrepareThread,
projectAttachments.length,
selectedSlashSkill,
@ -1378,6 +1393,7 @@ export function InputBox({
submitThreadMessage,
t.inputBox.goalTooLong,
t.inputBox.pleaseWaitStreaming,
t.inputBox.startTurnUnavailable,
],
);
@ -1478,6 +1494,10 @@ export function InputBox({
// A denied runs:cancel role sees a disabled stop affordance, not a removed
// one — the composer must still show that a turn is in flight.
const stopDenied = status === "streaming" && !canStopStreaming;
// Mirror for runs:create on the send side. While streaming the button is
// the stop affordance (gated above), so the send denial only applies to
// the send state.
const sendDenied = status !== "streaming" && !canCreateRuns;
const inputPolishUndoAvailable =
!polishingInput &&
inputPolishUndo !== null &&
@ -2881,12 +2901,12 @@ export function InputBox({
</ModelPicker>
<PromptInputSubmit
className="rounded-full"
disabled={composerLocked || stopDenied}
disabled={composerLocked || stopDenied || sendDenied}
variant="outline"
status={status}
// A bare disabled stop square reads as a broken composer;
// explain the permission boundary (native title, since a
// Radix tooltip won't fire on a disabled button). Spread
// A bare disabled square reads as a broken composer; explain
// the permission boundary (native title, since a Radix
// tooltip won't fire on a disabled button). Spread
// conditionally: an explicitly-undefined aria-label would
// clobber PromptInputSubmit's default aria-label="Submit"
// and strip the submit control's accessible name.
@ -2895,7 +2915,12 @@ export function InputBox({
"aria-label": t.inputBox.stopStreamingUnavailable,
title: t.inputBox.stopStreamingUnavailable,
}
: {})}
: sendDenied
? {
"aria-label": t.inputBox.startTurnUnavailable,
title: t.inputBox.startTurnUnavailable,
}
: {})}
onClick={(e) => {
if (status === "streaming") {
e.preventDefault();

View File

@ -7,6 +7,7 @@ import type { User } from "./types";
export const PERMISSIONS = {
THREADS_DELETE: "threads:delete",
RUNS_CANCEL: "runs:cancel",
RUNS_CREATE: "runs:create",
} as const;
/**

View File

@ -445,6 +445,7 @@ export const enUS: Translations = {
pleaseWaitStreaming: "Please wait for the current response to finish.",
stopStreamingUnavailable:
"Stopping the running turn is not permitted for your role.",
startTurnUnavailable: "Starting a new turn is not permitted for your role.",
},
// Sidebar

View File

@ -348,6 +348,7 @@ export interface Translations {
)[];
pleaseWaitStreaming: string;
stopStreamingUnavailable: string;
startTurnUnavailable: string;
};
// Sidebar

View File

@ -416,6 +416,7 @@ export const zhCN: Translations = {
],
pleaseWaitStreaming: "请等待当前响应完成。",
stopStreamingUnavailable: "你的角色无权停止正在运行的回合。",
startTurnUnavailable: "你的角色无权开启新的回合。",
},
// Sidebar

View File

@ -0,0 +1,305 @@
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import type { ReactNode } from "react";
import { toast } from "sonner";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { InputBox } from "@/components/workspace/input-box";
import { ThreadContext } from "@/components/workspace/messages/context";
import { AuthProvider } from "@/core/auth/AuthProvider";
import { DEFAULT_LOCALE } from "@/core/i18n";
import { I18nProvider } from "@/core/i18n/context";
rs.mock("next/navigation", () => ({
useRouter: () => ({ push: rs.fn(), replace: rs.fn(), refresh: rs.fn() }),
usePathname: () => "/workspace",
useSearchParams: () => new URLSearchParams(),
}));
// The composer's model selector is irrelevant to send gating; keep the
// react-query + network machinery out of the way entirely.
rs.mock("@/core/models/hooks", () => ({
useModels: () => ({
models: [],
tokenUsageEnabled: false,
isLoading: false,
isFetching: false,
error: null,
refetch: rs.fn(),
}),
}));
function getSubmitButton(container: HTMLElement): HTMLButtonElement {
const button = container.querySelector('button[type="submit"]');
if (!(button instanceof HTMLButtonElement)) {
throw new Error("submit button not rendered");
}
return button;
}
function typeText(container: HTMLElement, value: string): void {
const textarea = container.querySelector("textarea");
if (!(textarea instanceof HTMLTextAreaElement)) {
throw new Error("composer textarea not rendered");
}
fireEvent.change(textarea, { target: { value } });
}
function renderComposer({
canCreateRuns,
onSubmit,
onPrepareThread,
onGoalChange,
isWelcomeMode = false,
}: {
canCreateRuns?: boolean;
onSubmit: () => void;
onPrepareThread?: () => void | Promise<void>;
onGoalChange?: () => void;
isWelcomeMode?: boolean;
}) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const tree = (onSubmitProp: () => void): ReactNode => (
<I18nProvider initialLocale={DEFAULT_LOCALE}>
<QueryClientProvider client={queryClient}>
<AuthProvider
initialUser={{
id: "user-1",
email: "user@example.test",
system_role: "user",
needs_setup: false,
oauth_provider: null,
}}
>
<ThreadContext.Provider
value={{ thread: { messages: [] } as never, isMock: true }}
>
<PromptInputProvider>
<InputBox
threadId="thread-1"
status="ready"
context={{ mode: "flash" } as never}
onSubmit={onSubmitProp}
canCreateRuns={canCreateRuns}
onPrepareThread={onPrepareThread}
onGoalChange={onGoalChange}
isWelcomeMode={isWelcomeMode}
/>
</PromptInputProvider>
</ThreadContext.Provider>
</AuthProvider>
</QueryClientProvider>
</I18nProvider>
);
return render(tree(onSubmit));
}
beforeEach(() => {
window.sessionStorage.clear();
});
afterEach(() => {
rs.restoreAllMocks();
rs.unstubAllGlobals();
cleanup();
});
function submitForm(container: HTMLElement, text: string) {
typeText(container, text);
const form = container.querySelector("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("composer form not rendered");
}
fireEvent.submit(form);
}
function mockCommandRequests(events: string[] = []) {
const fetchMock = rs.fn(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const isCommand = url.endsWith("/goal") || url.endsWith("/compact");
if (isCommand) {
events.push(init?.method ?? "GET");
}
return new Response(
JSON.stringify(isCommand ? { goal: null, compacted: true } : {}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
},
);
rs.stubGlobal("fetch", fetchMock);
return fetchMock;
}
describe("InputBox send gating (runs:create)", () => {
it.each([false, true])(
"rejects goal-set before thread preparation or goal writes (welcome=%s)",
async (isWelcomeMode) => {
const events: string[] = [];
mockCommandRequests(events);
const info = rs.spyOn(toast, "info");
const success = rs.spyOn(toast, "success");
const onSubmit = rs.fn();
const onPrepareThread = rs.fn();
const onGoalChange = rs.fn();
const { container } = renderComposer({
canCreateRuns: false,
onSubmit,
onPrepareThread,
onGoalChange,
isWelcomeMode,
});
submitForm(container, "/goal finish all tests");
await waitFor(() =>
expect(info).toHaveBeenCalledWith(
expect.stringContaining("not permitted"),
),
);
expect(onPrepareThread).not.toHaveBeenCalled();
expect(events).toEqual([]);
expect(onSubmit).not.toHaveBeenCalled();
expect(onGoalChange).not.toHaveBeenCalled();
expect(success).not.toHaveBeenCalled();
expect(container.querySelector("textarea")?.value).toBe(
"/goal finish all tests",
);
},
);
it("preserves prepare, goal-save, and run-start ordering for an allowed role", async () => {
const events: string[] = [];
mockCommandRequests(events);
const onSubmit = rs.fn(() => {
events.push("submit");
});
const { container } = renderComposer({
canCreateRuns: true,
onSubmit,
onPrepareThread: () => {
events.push("prepare");
},
});
submitForm(container, "/goal finish all tests");
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(events).toEqual(["prepare", "PUT", "submit"]);
});
it.each([
["/goal", "GET"],
["/goal clear", "DELETE"],
["/compact", "POST"],
])("allows %s without runs:create", async (command, method) => {
const events: string[] = [];
mockCommandRequests(events);
const info = rs.spyOn(toast, "info");
const onSubmit = rs.fn();
const onPrepareThread = rs.fn();
const { container } = renderComposer({
canCreateRuns: false,
onSubmit,
onPrepareThread,
});
submitForm(container, command);
await waitFor(() =>
expect(container.querySelector("textarea")?.value).toBe(""),
);
expect(events).toEqual([method]);
expect(onSubmit).not.toHaveBeenCalled();
expect(onPrepareThread).not.toHaveBeenCalled();
expect(info).not.toHaveBeenCalledWith(
expect.stringContaining("not permitted"),
);
});
it("disables the send affordance for a denied role and never fires onSubmit", () => {
const onSubmit = rs.fn();
const { container } = renderComposer({
canCreateRuns: false,
onSubmit,
});
const submit = getSubmitButton(container);
expect(submit.disabled).toBe(true);
fireEvent.click(submit);
expect(onSubmit).not.toHaveBeenCalled();
});
it("blocks the Enter submit path for a denied role (handler-level gate)", async () => {
const onSubmit = rs.fn();
const { container } = renderComposer({
canCreateRuns: false,
onSubmit,
});
typeText(container, "hello");
const form = container.querySelector("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("composer form not rendered");
}
fireEvent.submit(form);
// PromptInput resolves the submit through a microtask chain (file
// conversion promise) before calling onSubmit; flush it so an
// ungated composer provably fires and this test is a real guard.
await new Promise((resolve) => setTimeout(resolve, 50));
expect(onSubmit).not.toHaveBeenCalled();
});
it("explains the permission boundary on the disabled affordance", () => {
const { container } = renderComposer({
canCreateRuns: false,
onSubmit: rs.fn(),
});
const submit = getSubmitButton(container);
expect(submit.getAttribute("aria-label")).toContain("not permitted");
expect(submit.title).toContain("not permitted");
});
it("keeps send enabled for an unresolved permission list (default)", async () => {
const onSubmit = rs.fn();
const { container } = renderComposer({ onSubmit });
const submit = getSubmitButton(container);
expect(submit.disabled).toBe(false);
typeText(container, "hello");
const form = container.querySelector("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("composer form not rendered");
}
fireEvent.submit(form);
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
});
it("keeps the base Submit accessible name when send is not denied", () => {
// Mirror of the stop-gating regression: the aria-label must be spread
// conditionally, never explicitly-undefined, or it clobbers
// PromptInputSubmit's default aria-label="Submit".
renderComposer({ onSubmit: rs.fn() });
const submit = screen.getByRole("button", { name: "Submit" });
expect(submit.tagName).toBe("BUTTON");
});
});

View File

@ -41,6 +41,16 @@ describe("hasPermission", () => {
expect(hasPermission(null, PERMISSIONS.THREADS_DELETE)).toBe(true);
expect(hasPermission(undefined, PERMISSIONS.THREADS_DELETE)).toBe(true);
});
it("maps the runs:create composer gate onto the /me permission list", () => {
expect(PERMISSIONS.RUNS_CREATE).toBe("runs:create");
expect(
hasPermission({ permissions: ["runs:create"] }, PERMISSIONS.RUNS_CREATE),
).toBe(true);
expect(
hasPermission({ permissions: ["runs:read"] }, PERMISSIONS.RUNS_CREATE),
).toBe(false);
});
});
describe("userSchema permissions field", () => {