open-swe/ui/src/lib/agents/queries.ts
seahaven-openswe[bot] f87847baa4
Some checks are pending
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
CI / Docker build smoke (push) Waiting to run
CI / Triage ledger up to date (push) Waiting to run
CI / ui bun.lock in sync (push) Waiting to run
feat: port plan-review & workflow-approval UX (#159)
* feat: port plan-review & workflow-approval UX (#135)

Port six upstream commits onto dev:

- c03a6be7 (already ported): keep plan guidance high-level
- 546042a4: add workflow approval UI with diff preview, approval URLs,
  web review links, and polling for approval status during active runs
- 216cf181: remove workflow token elevation; approved pushes pass
  through directly without proxy token rewriting
- 3dbc0282: preserve plan redirects after login by accepting relative
  same-origin redirect_to values and rejecting blocked paths
- bb104d93: submit plan comments with cmd+enter
- 90cb6caa: terse Slack replies, shared content via save_plan outside
  plan mode (PLAN_STATUS_SHARED), reject shared-content mutations

Refs: #135

* fix: restore login page render and clear CI lint/format

The plan-review port removed the authRedirectUrl import from login.tsx
but left its call site, crashing the login page at runtime (blank page,
no 'Sign in to open-swe'). Pass the relative path straight to loginUrl,
matching the plan route and the backend relative-redirect handling.

Also drop an unused os import in the guard test and reformat
workflow_push_guard.py to satisfy ruff.

* fix: carry workflows:write on the standing proxy token

Complete the half-ported upstream 216cf181 cascade. The port dropped
_run_with_workflow_token from the guard but missed the paired github_app
change, so an approved .github/workflows push ran with the base token
(no workflows:write) and GitHub 403'd it.

Add workflows:write to BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS and delete the
now-orphaned WORKFLOW_RUNTIME_PROXY_TOKEN_PERMISSIONS constant; update the
github_app and proxy_auth tests to match. The HITL approval gate in
workflow_push_guard.py is unchanged — this only lets the standing token
push once a human approves.

* fix: restore transient workflow-token elevation (revert standing workflows:write)

The standing GitHub-App proxy token (BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS) is
ALWAYS-ON, so carrying workflows:write on it made the fork's HITL workflow-push
guard the sole control over unapproved workflow pushes. The guard's git-push
parser has gaps (obfuscated-expansion push, `gh api` REST contents PUT,
fully-qualified cross-branch refspecs); with a permanently workflows-scoped
token those gaps become live unapproved-workflow-push exploits (1 critical, 2
high — security review BLOCK on #159).

Restore dev's transient-elevation model:
- Drop workflows:write from BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS; re-add the
  WORKFLOW_RUNTIME_PROXY_TOKEN_PERMISSIONS constant (base + workflows:write).
- Re-introduce _run_with_workflow_token in the guard: it mints the
  workflows-scoped token via refresh_proxy_token around the approved,
  guard-normalized fixed_command, then downscopes to RUNTIME then BASE in a
  finally. Route the approval branch through it.
- Restore the dev token/elevation tests.

The standing token no longer carries workflows:write, so the three parser
bypasses hit GitHub 403 again; an approved push still succeeds because the
elevation grants workflows:write only around the normalized command. Keeps all
of #159's diff-preview / approval-URL / Slack-card guard additions.

* fix: reject protocol-relative path from sanitizeAuthRedirect (open redirect)

sanitizeAuthRedirect returned parsed.pathname+search+hash, which `new URL` can
resolve to a protocol-relative `//host` (e.g. input `/..//evil.com` normalizes
same-origin, passing the origin check, but yields a path starting with `//`).
ClientRedirect / login.tsx feed that path to window.location.replace, so it
navigates cross-origin — an open redirect. Reject any resolved path that is not
a single-leading-slash path (`^/[^/]`), falling back to the default. Adds
coverage for `/..//evil.com`, `/.//evil.com`, and `//evil.com`.

* fix: log SECURITY error when workflow-token downscope fails

The elevate->push->downscope finally block was silent on failure. If both
refresh_proxy_token calls fail, the sandbox retains workflows:write for the
rest of the run with no signal. Log a SECURITY error on the partial and full
downscope-failure paths so the retention is observable.

Addresses the GPT-4.1 cross-family review of the token-scope remediation.

---------

Co-authored-by: amoussa1229 <166072409+amoussa1229@users.noreply.github.com>
Co-authored-by: Adam Moussa <adam@seahavenind.com>
2026-07-09 16:03:13 -04:00

324 lines
9.6 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { useEffect } from "react"
import { agentsApi } from "./api"
import type { QueryClient } from "@tanstack/react-query"
import type {
ScheduleUpdateRequest,
SidebarThreads,
ThreadsPageParams,
WorkflowPushApproval,
WorkflowPushApprovalsResponse,
} from "./api"
import type { AgentThread, Chunk, ImageChunk, Message } from "./types"
export const agentThreadKeys = {
lists: ["agent-threads", "lists"] as const,
sidebar: (params: { activeLimit: number; resolvedLimit: number }) =>
["agent-threads", "lists", "sidebar", params] as const,
detail: (threadId: string) => ["agent-threads", threadId] as const,
prDiff: (threadId: string) => ["agent-threads", threadId, "pr-diff"] as const,
workflowApprovals: (threadId: string) =>
["agent-threads", threadId, "workflow-approvals"] as const,
page: (params: ThreadsPageParams) =>
["agent-threads", "lists", "page", params] as const,
}
export function invalidateAgentThreadLists(queryClient: QueryClient): void {
void queryClient.invalidateQueries({ queryKey: agentThreadKeys.lists })
}
export function seedAgentThreadLists(
queryClient: QueryClient,
thread: AgentThread
): void {
queryClient.setQueriesData<SidebarThreads>(
{ queryKey: ["agent-threads", "lists", "sidebar"] },
(prev) => {
if (!prev) return prev
const activeItems = [
thread,
...prev.active.items.filter((item) => item.id !== thread.id),
].slice(0, prev.active.limit)
const resolvedItems = prev.resolved.items.filter(
(item) => item.id !== thread.id
)
return {
...prev,
active: { ...prev.active, items: activeItems },
resolved: { ...prev.resolved, items: resolvedItems },
}
}
)
}
export const agentScheduleKeys = {
all: ["agent-schedules"] as const,
}
// Sidebar lists and detail reads return the same per-thread summary, so warming
// the detail cache from the already-fetched sidebar avoids a fan-out of one
// request per thread. Navigation stays instant; the real (mark-viewed) fetch
// fires only when a thread is actually opened. The active thread is skipped so
// its live detail query stays the source of truth.
export function useSeedAgentThreadDetails(
threads: Array<AgentThread>,
activeThreadId?: string
) {
const queryClient = useQueryClient()
useEffect(() => {
for (const thread of threads) {
if (thread.id === activeThreadId) continue
// Seed as already-stale: the detail GET is what marks a thread viewed
// server-side, so opening a seeded entry must still refetch despite the
// detail query's `staleTime` (which exists for the optimistic seed).
queryClient.setQueryData(agentThreadKeys.detail(thread.id), thread, {
updatedAt: 0,
})
}
}, [activeThreadId, queryClient, threads])
}
const SIDEBAR_ACTIVE_LIMIT = 50
function sidebarThreads(data?: SidebarThreads): Array<AgentThread> {
return [...(data?.active.items ?? []), ...(data?.resolved.items ?? [])]
}
function sidebarRefetchInterval(query: { state: { data?: SidebarThreads } }) {
return sidebarThreads(query.state.data).some(
(thread) => thread.status === "running"
)
? 2000
: false
}
export function useSidebarThreads(resolvedLimit: number) {
const params = {
activeLimit: SIDEBAR_ACTIVE_LIMIT,
resolvedLimit,
}
return useQuery({
queryKey: agentThreadKeys.sidebar(params),
queryFn: () => agentsApi.listSidebarThreads(params),
refetchInterval: sidebarRefetchInterval,
placeholderData: (prev) => prev,
})
}
export function useAgentThread(threadId: string) {
return useQuery({
queryKey: agentThreadKeys.detail(threadId),
queryFn: () => agentsApi.getThread(threadId),
// Lets the optimistic detail seeded by `AgentsHome` survive until the
// proxied run.start stamps the server-side thread; an immediate refetch
// would 404 and bounce the route back to /agents.
staleTime: 30_000,
})
}
export function useAgentThreadPrDiff(threadId: string, enabled: boolean) {
return useQuery({
queryKey: agentThreadKeys.prDiff(threadId),
queryFn: () => agentsApi.getThreadPrDiff(threadId),
enabled,
staleTime: 30_000,
retry: false,
})
}
export function useWorkflowApprovals(
threadId: string,
options: { pollWhileActive?: boolean } = {}
) {
return useQuery({
queryKey: agentThreadKeys.workflowApprovals(threadId),
queryFn: () => agentsApi.listWorkflowApprovals(threadId),
enabled: Boolean(threadId),
refetchInterval: (query) =>
options.pollWhileActive ||
query.state.data?.approvals?.some(
(approval) => approval.status === "pending"
)
? 3000
: false,
retry: false,
})
}
export function useWorkflowApprovalDecision(threadId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (vars: {
fingerprint: string
decision: "approve" | "reject"
}) =>
vars.decision === "approve"
? agentsApi.approveWorkflowPush(threadId, vars.fingerprint)
: agentsApi.rejectWorkflowPush(threadId, vars.fingerprint),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: agentThreadKeys.workflowApprovals(threadId),
})
void queryClient.invalidateQueries({
queryKey: agentThreadKeys.detail(threadId),
})
invalidateAgentThreadLists(queryClient)
},
})
}
export function useAgentSchedules() {
return useQuery({
queryKey: agentScheduleKeys.all,
queryFn: () => agentsApi.listSchedules(),
})
}
export function useCreateAgentSchedule() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: agentsApi.createSchedule,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: agentScheduleKeys.all })
},
})
}
export function useUpdateAgentSchedule() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (vars: { scheduleId: string; body: ScheduleUpdateRequest }) =>
agentsApi.updateSchedule(vars.scheduleId, vars.body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: agentScheduleKeys.all })
},
})
}
export function useDeleteAgentSchedule() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: agentsApi.deleteSchedule,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: agentScheduleKeys.all })
},
})
}
export interface CreateAgentThreadVariables {
prompt: string
images?: Array<ImageChunk>
repo?: string | null
repo_explicitly_none?: boolean
model_id?: string | null
effort?: string | null
}
/**
* Build the placeholder thread shown the instant a run is started from the
* home page — before the server has stamped the thread record. Seeded into
* the detail + list caches by `AgentsHome` so the `$threadId` route renders
* immediately (the 30s `staleTime` keeps it from refetching into a 404), then
* reconciled to server truth by the list's running refetch + the stream's
* `onCreated` / `onCompleted` invalidations.
*/
export function optimisticThread(
threadId: string,
vars: CreateAgentThreadVariables
): AgentThread {
const now = Date.now()
const text = vars.prompt.trim()
const repoFullName = vars.repo ?? ""
const chunks: Array<Chunk> = [
...(vars.images ?? []),
...(text ? [{ kind: "text", text } satisfies Chunk] : []),
]
const message: Message = {
id: `optimistic-user-${threadId}`,
author: "user",
timestamp: new Date(now).toISOString(),
chunks,
}
return {
id: threadId,
title: text.slice(0, 80) || "New agent",
repo: repoFullName.split("/")[1] ?? "",
repoFullName,
branch: "main",
model: vars.model_id ?? "Default",
effort: vars.effort ?? null,
source: "dashboard",
status: "running",
viewed: true,
viewedAt: now,
isOwner: true,
createdAt: now,
updatedAt: now,
traceUrl: null,
messages: message.chunks.length > 0 ? [message] : [],
}
}
export interface SendAgentMessageVariables {
content: string
images?: Array<ImageChunk>
model_id?: string | null
effort?: string | null
plan_mode?: boolean
}
export function useCancelAgentThread(threadId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => agentsApi.cancelThread(threadId),
onSuccess: (thread) => {
queryClient.setQueryData(agentThreadKeys.detail(threadId), thread)
invalidateAgentThreadLists(queryClient)
},
})
}
export function useDeleteAgentThread() {
const queryClient = useQueryClient()
const navigate = useNavigate()
return useMutation({
mutationFn: (threadId: string) => agentsApi.deleteThread(threadId),
onSuccess: (_, threadId) => {
queryClient.removeQueries({ queryKey: agentThreadKeys.detail(threadId) })
invalidateAgentThreadLists(queryClient)
const path = window.location.pathname
if (path.includes(`/agents/${threadId}`)) {
navigate({ to: "/agents" })
}
},
})
}
export function useResolveAgentThread() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (vars: { threadId: string; resolved: boolean }) =>
agentsApi.resolveThread(vars.threadId, vars.resolved),
onSuccess: (thread, vars) => {
queryClient.setQueryData(agentThreadKeys.detail(vars.threadId), thread)
invalidateAgentThreadLists(queryClient)
},
})
}
export function useThreadsPage(params: ThreadsPageParams) {
return useQuery({
queryKey: agentThreadKeys.page(params),
queryFn: () => agentsApi.listThreadsPage(params),
placeholderData: (prev) => prev,
})
}