mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-07 16:19:09 +00:00
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 (#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>
324 lines
9.6 KiB
TypeScript
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,
|
|
})
|
|
}
|