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>
252 lines
9.7 KiB
TypeScript
252 lines
9.7 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react"
|
|
import { Link } from "@tanstack/react-router"
|
|
import { useStreamContext as useAgentThreadStream } from "@langchain/react"
|
|
import { Map as MapIcon } from "lucide-react"
|
|
|
|
import type {
|
|
AgentThread,
|
|
Message,
|
|
QueuedThreadMessage,
|
|
} from "@/lib/agents/types"
|
|
import type { ModelSelection } from "@/lib/agents/provider/useModelOptions"
|
|
import {
|
|
AgentGitPanel,
|
|
PANEL_MIN_CHAT_WIDTH,
|
|
readStoredPanelCollapsed,
|
|
writeStoredPanelCollapsed,
|
|
} from "@/components/agents/AgentGitPanel"
|
|
import { AgentPromptBar } from "@/components/agents/AgentPromptBar"
|
|
import { Messages } from "@/components/agents/messages"
|
|
import { streamMessagesToUi } from "@/lib/agents/streamMessagesToUi"
|
|
import { messageArrivalTimestamp } from "@/lib/agents/messageTimestamps"
|
|
import { useSubmitAgentMessage } from "@/lib/agents/provider/useSubmitAgentMessage"
|
|
import { useModelOptions } from "@/lib/agents/provider/useModelOptions"
|
|
import { useIsMobile } from "@/lib/useIsMobile"
|
|
import { cn } from "@/lib/utils"
|
|
import { WorkflowApprovalCard } from "@/components/agents/WorkflowApprovalCard"
|
|
|
|
interface AgentThreadViewProps {
|
|
thread: AgentThread
|
|
}
|
|
|
|
function messageText(message: Message): string {
|
|
return message.chunks
|
|
.map((chunk) => (chunk.kind === "text" ? chunk.text : ""))
|
|
.join("\n")
|
|
.trim()
|
|
}
|
|
|
|
function visibleQueuedMessages(
|
|
queuedMessages: Array<QueuedThreadMessage> | undefined,
|
|
messages: Array<Message>
|
|
): Array<QueuedThreadMessage> {
|
|
const queued = queuedMessages ?? []
|
|
if (queued.length === 0) return queued
|
|
|
|
const userMessages = messages
|
|
.filter((message) => message.author === "user")
|
|
.map((message) => ({
|
|
text: messageText(message),
|
|
timestamp: Date.parse(message.timestamp),
|
|
consumed: false,
|
|
}))
|
|
|
|
return queued.filter((queuedMessage) => {
|
|
const queuedText = queuedMessage.content.trim()
|
|
if (!queuedText) return true
|
|
|
|
const match = userMessages.find((message) => {
|
|
if (message.consumed || !message.text.includes(queuedText)) return false
|
|
if (!Number.isFinite(message.timestamp)) return true
|
|
return message.timestamp >= queuedMessage.createdAt - 1000
|
|
})
|
|
if (!match) return true
|
|
|
|
match.consumed = true
|
|
return false
|
|
})
|
|
}
|
|
|
|
// The stream lives at the `/agents` layout (one persistent provider that
|
|
// survives the home → thread navigation), so this view only consumes it.
|
|
export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
|
const sendMessage = useSubmitAgentMessage(thread.id)
|
|
const stream = useAgentThreadStream()
|
|
const isMobile = useIsMobile()
|
|
|
|
const { models, defaultSelection } = useModelOptions()
|
|
const threadSelection = useMemo<ModelSelection | null>(() => {
|
|
if (!thread.model || !thread.effort) return null
|
|
const supported = models.some(
|
|
(m) => m.id === thread.model && m.efforts.includes(thread.effort ?? "")
|
|
)
|
|
if (!supported) return null
|
|
return { modelId: thread.model, effort: thread.effort }
|
|
}, [models, thread.model, thread.effort])
|
|
const [selection, setSelection] = useState<ModelSelection | null>(null)
|
|
const activeSelection = selection ?? threadSelection ?? defaultSelection
|
|
const [planMode, setPlanMode] = useState<boolean | null>(null)
|
|
const activePlanMode = planMode ?? thread.planMode ?? false
|
|
|
|
// Own the git panel's collapsed state so the plan banner can reserve space for
|
|
// the floating expand button the panel renders while collapsed.
|
|
const [panelCollapsed, setPanelCollapsed] = useState(() =>
|
|
readStoredPanelCollapsed()
|
|
)
|
|
const handlePanelCollapsedChange = useCallback((next: boolean) => {
|
|
setPanelCollapsed(next)
|
|
writeStoredPanelCollapsed(next)
|
|
}, [])
|
|
|
|
const baseMessages = useMemo<Array<Message>>(() => {
|
|
const live = streamMessagesToUi(
|
|
stream.messages,
|
|
stream.toolCalls,
|
|
stream.subagents,
|
|
messageArrivalTimestamp
|
|
)
|
|
if (live.length > 0) return live
|
|
// Optimistic transcript seeded by `AgentsHome` on thread creation (the
|
|
// only case where a fetched thread carries messages — `getThread` returns
|
|
// none). Bridges the brief gap before the SDK's optimistic `submit` echo
|
|
// lands in `stream.messages`.
|
|
if (thread.messages.length > 0) return thread.messages
|
|
return live
|
|
}, [stream.messages, stream.toolCalls, stream.subagents, thread.messages])
|
|
|
|
const isStreaming = thread.status === "running" || stream.isLoading
|
|
const queuedMessages = useMemo(
|
|
() => visibleQueuedMessages(thread.queuedMessages, baseMessages),
|
|
[baseMessages, thread.queuedMessages]
|
|
)
|
|
const hasMessages = baseMessages.length > 0
|
|
const hasConversation = hasMessages || queuedMessages.length > 0
|
|
const isThinking = stream.isLoading
|
|
const settingUpSandbox = isThinking && baseMessages.length === 0
|
|
// The transcript hydrates from the SDK (`GET …/state` → `stream.messages`).
|
|
// Show a loading state during that one-time fetch instead of the empty state.
|
|
const isHydrating = stream.isThreadLoading && !hasMessages
|
|
|
|
return (
|
|
<div className="flex min-w-0 flex-1">
|
|
<div
|
|
className="flex min-w-0 flex-1 flex-col"
|
|
style={isMobile ? undefined : { minWidth: PANEL_MIN_CHAT_WIDTH }}
|
|
>
|
|
{thread.status === "error" && (
|
|
<div className="border-b border-[var(--ui-border)] bg-[var(--ui-danger)]/10 px-4 py-2 text-xs text-[var(--ui-danger)]">
|
|
The last run hit an error before it could finish. Send another
|
|
message to retry.
|
|
</div>
|
|
)}
|
|
{thread.planStatus &&
|
|
thread.planStatus !== "approved" &&
|
|
thread.planStatus !== "cancelled" && (
|
|
<Link
|
|
to="/agents/$threadId/plan"
|
|
params={{ threadId: thread.id }}
|
|
data-testid="review-plan-link"
|
|
className={cn(
|
|
"flex items-center justify-between gap-2 border-b border-[var(--ui-border)] bg-[var(--ui-panel)] px-4 py-2 text-xs text-[var(--ui-text)] hover:bg-[var(--ui-panel-2)]",
|
|
// The collapsed panel floats a fixed expand button in the
|
|
// top-right corner; clear it so it never covers "Review plan →".
|
|
panelCollapsed && "pr-14"
|
|
)}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<MapIcon className="size-3.5 text-[var(--ui-accent)]" />
|
|
{thread.planStatus === "ready"
|
|
? "A plan is ready for your review."
|
|
: thread.planStatus === "shared"
|
|
? "The agent shared a longer response."
|
|
: thread.planStatus === "revising"
|
|
? "The agent is revising the plan."
|
|
: "The agent is writing a plan."}
|
|
</span>
|
|
<span className="font-medium text-[var(--ui-accent)]">
|
|
{thread.planStatus === "shared"
|
|
? "Open response →"
|
|
: "Review plan →"}
|
|
</span>
|
|
</Link>
|
|
)}
|
|
<WorkflowApprovalCard
|
|
threadId={thread.id}
|
|
pollWhileActive={isStreaming}
|
|
/>
|
|
{hasConversation ? (
|
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
<Messages
|
|
messages={baseMessages}
|
|
queuedMessages={queuedMessages}
|
|
isStreaming={isStreaming}
|
|
streamIsLoading={stream.isLoading}
|
|
isThinking={isThinking}
|
|
settingUpSandbox={settingUpSandbox}
|
|
contentWidthClass="max-w-3xl"
|
|
/>
|
|
<div className="shrink-0 px-4 pb-4">
|
|
<div className="mx-auto w-full max-w-3xl min-w-0">
|
|
<AgentPromptBar
|
|
placeholder="Add a follow up"
|
|
compact
|
|
busy={isStreaming}
|
|
onSubmit={(content, images) =>
|
|
sendMessage.mutateAsync({
|
|
content,
|
|
images,
|
|
model_id: activeSelection?.modelId ?? null,
|
|
effort: activeSelection?.effort ?? null,
|
|
plan_mode: activePlanMode,
|
|
})
|
|
}
|
|
models={models}
|
|
selection={activeSelection}
|
|
onSelectionChange={setSelection}
|
|
planMode={activePlanMode}
|
|
onPlanModeChange={setPlanMode}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : isHydrating ? (
|
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6">
|
|
<p className="text-xs text-[var(--ui-text-dim)]">
|
|
Loading conversation…
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6">
|
|
<p className="text-xs text-[var(--ui-text-dim)]">
|
|
This thread has no messages yet.
|
|
</p>
|
|
<div className="w-full max-w-3xl">
|
|
<AgentPromptBar
|
|
placeholder="Send the first message"
|
|
compact
|
|
busy={isStreaming}
|
|
onSubmit={(content, images) =>
|
|
sendMessage.mutateAsync({
|
|
content,
|
|
images,
|
|
model_id: activeSelection?.modelId ?? null,
|
|
effort: activeSelection?.effort ?? null,
|
|
})
|
|
}
|
|
models={models}
|
|
selection={activeSelection}
|
|
onSelectionChange={setSelection}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<AgentGitPanel
|
|
thread={thread}
|
|
messages={baseMessages}
|
|
collapsed={panelCollapsed}
|
|
onCollapsedChange={handlePanelCollapsedChange}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|