Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
762 changes: 514 additions & 248 deletions crates/agent-gateway/internal/proto/v2/gateway.pb.go

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions crates/agent-gateway/internal/protocol/pbws/guard.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ func vetAgentRequest(sm session.AgentView, env *gatewayv2.GatewayEnvelope) error
*gatewayv2.GatewayEnvelope_FsReadWorkspaceImage,
*gatewayv2.GatewayEnvelope_ChatQueue:
return nil
case *gatewayv2.GatewayEnvelope_ChatFileOpen:
return vetChatFileOpen(payload.ChatFileOpen)

// ---- 带功能门控 / 限额的直通臂 ----
case *gatewayv2.GatewayEnvelope_GitRequest:
Expand Down Expand Up @@ -105,6 +107,35 @@ func vetAgentRequest(sm session.AgentView, env *gatewayv2.GatewayEnvelope) error
}
}

func vetChatFileOpen(req *gatewayv2.ChatFileOpenRequest) error {
if req == nil || strings.TrimSpace(req.GetConversationId()) == "" || len(req.GetConversationId()) > 256 {
return errors.New("conversation is unavailable")
}
if strings.TrimSpace(req.GetWorkdir()) == "" || strings.TrimSpace(req.GetPath()) == "" {
return errors.New("linked file request is incomplete")
}
if len(req.GetWorkdir()) > 32768 || len(req.GetPath()) > 32768 {
return errors.New("linked file request is too large")
}
switch strings.TrimSpace(req.GetSource()) {
case "absolute", "relative", "file-url":
default:
return errors.New("linked file source is invalid")
}
if (req.Line != nil && req.GetLine() == 0) ||
(req.EndLine != nil && req.GetEndLine() == 0) ||
(req.Column != nil && req.GetColumn() == 0) {
return errors.New("linked file location is invalid")
}
if req.Line == nil && (req.EndLine != nil || req.Column != nil) {
return errors.New("linked file location is invalid")
}
if req.Line != nil && req.EndLine != nil && req.GetEndLine() < req.GetLine() {
return errors.New("linked file location is invalid")
}
return nil
}

// gitActionIsWrite 判定 git 直通请求是否为写操作:写操作受桌面端 Remote 设置
// enable_web_git 门控,读操作(status/log/diff 等)始终放行。
func gitActionIsWrite(action string) bool {
Expand Down
39 changes: 39 additions & 0 deletions crates/agent-gateway/internal/protocol/pbws/guard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,42 @@ func TestVetAgentRequestAllowsProviderUsage(t *testing.T) {
t.Fatalf("vetAgentRequest() error = %v", err)
}
}

func TestVetAgentRequestAllowsValidChatFileOpen(t *testing.T) {
line := uint32(12)
column := uint32(4)
env := &gatewayv2.GatewayEnvelope{
Payload: &gatewayv2.GatewayEnvelope_ChatFileOpen{
ChatFileOpen: &gatewayv2.ChatFileOpenRequest{
ConversationId: "conversation-1",
Workdir: `C:\work`,
Path: `src\a.ts`,
Source: "relative",
Line: &line,
Column: &column,
},
},
}

if err := vetAgentRequest(session.AgentView{}, env); err != nil {
t.Fatalf("vetAgentRequest() error = %v", err)
}
}

func TestVetAgentRequestRejectsMalformedChatFileOpen(t *testing.T) {
zero := uint32(0)
tests := []*gatewayv2.ChatFileOpenRequest{
nil,
{ConversationId: "", Workdir: "/work", Path: "a.ts", Source: "relative"},
{ConversationId: "conversation-1", Workdir: "/work", Path: "a.ts", Source: "javascript"},
{ConversationId: "conversation-1", Workdir: "/work", Path: "a.ts", Source: "relative", Line: &zero},
}
for _, request := range tests {
env := &gatewayv2.GatewayEnvelope{
Payload: &gatewayv2.GatewayEnvelope_ChatFileOpen{ChatFileOpen: request},
}
if err := vetAgentRequest(session.AgentView{}, env); err == nil {
t.Fatalf("vetAgentRequest(%+v) unexpectedly succeeded", request)
}
}
}
24 changes: 24 additions & 0 deletions crates/agent-gateway/proto/v2/gateway.proto
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ message GatewayEnvelope {
ManagedProcessRequest managed_process_request = 91;
HistoryBranchRequest history_branch = 92;
ProviderUsageRequest provider_usage = 93;
ChatFileOpenRequest chat_file_open = 94;
}

// Legacy tunnel control/frame payloads (pre-rewrite protocol) and the
Expand Down Expand Up @@ -132,6 +133,7 @@ message AgentEnvelope {
ChatIngressBatch chat_ingress_batch = 95;
ChatIngressResume chat_ingress_resume = 96;
ChatIngressFragment chat_ingress_fragment = 97;
ChatFileOpenResponse chat_file_open_resp = 98;
ErrorResponse error = 99;
}

Expand Down Expand Up @@ -1064,6 +1066,28 @@ message FsReadWorkspaceImageResponse {
string content_hash = 6;
}

message ChatFileOpenRequest {
string conversation_id = 1;
string workdir = 2;
string path = 3;
string source = 4;
optional uint32 line = 5;
optional uint32 end_line = 6;
optional uint32 column = 7;
bool open_in_file_manager = 8;
}

message ChatFileOpenResponse {
string action = 1;
string kind = 2;
string workdir = 3;
string path = 4;
optional uint32 line = 5;
optional uint32 end_line = 6;
optional uint32 column = 7;
bool outside_workspace = 8;
}

message FsWriteTextRequest {
string workdir = 1;
string path = 2;
Expand Down
80 changes: 80 additions & 0 deletions crates/agent-gateway/web/src/app/GatewayApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { useConfirmDialog } from "@/components/ui/confirm-dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { LocaleContext, t as translate } from "@/i18n";
import { registerAskUserQuestionAnswerHandler } from "@/lib/chat/askUserQuestionBridge";
import type { ChatFileLink } from "@/lib/chat/chatFileLinks";
import type { ChatHistorySummary } from "@/lib/chat/chatHistory";
import { buildModelOptions } from "@/lib/chat/chatPageHelpers";
import type { HistoryMessageRef } from "@/lib/chat/conversationState";
Expand All @@ -40,6 +41,7 @@ import {
trimLeadingHeadlessEntries,
} from "@/lib/chat/historyWindow";
import type { CodeMentionReference } from "@/lib/chat/mentionReferences";
import { openChatFileLink } from "@/lib/chat/openChatFileLink";
import { isChatRuntimeProtocolIncompatible } from "@/lib/chat/runtimeCompatibility";
import { createActivityStore } from "@/lib/chat/stream/activityStore";
import {
Expand Down Expand Up @@ -4138,6 +4140,83 @@ export default function GatewayApp() {
}),
[handleChangedFileOpenDiff, handleChangedFileReveal, handleOpenWorkspaceFile],
);
const handleOpenChatFileLink = useCallback(
(link: ChatFileLink) => {
const conversationWorkdir = displayedConversationWorkdir.trim();
if (!displayedConversationId || !conversationWorkdir) {
addNotify("error", "The conversation working directory is unavailable.");
return;
}
const request = {
...link,
conversationId: displayedConversationId,
workdir: conversationWorkdir,
};
void openChatFileLink(request)
.then(async (result) => {
if (result.action === "opened" || result.action === "revealed") return;
const resultWorkdir = result.workdir?.trim() ?? "";
const resultPath = result.path?.trim() ?? "";
if (!resultWorkdir || !resultPath) {
addNotify("error", "The linked file could not be opened.");
return;
}
if (result.action === "directory") {
if (workspaceProjectPathKey(resultWorkdir) === terminalProjectPathKey) {
handleChangedFileReveal(resultPath);
return;
}
const fallback = await openChatFileLink({ ...request, openInFileManager: true });
if (fallback.action !== "opened") {
addNotify("error", "The linked directory could not be opened.");
}
return;
}
const workspaceRequest = {
projectPathKey: workspaceProjectPathKey(resultWorkdir),
workdir: resultWorkdir,
path: resultPath,
};
if (
!result.outsideWorkspace &&
workspaceRequest.projectPathKey === terminalProjectPathKey
) {
handleChangedFileReveal(resultPath);
}
if (result.action === "preview") {
openWorkspaceFilePreview(workspaceRequest);
return;
}
openWorkspaceEditorFile({
...workspaceRequest,
line: result.line,
endLine: result.endLine,
column: result.column,
});
})
.catch((error: unknown) => {
const message = asErrorMessage(error, "The linked file could not be opened.");
const normalized = message.toLowerCase();
addNotify(
"error",
normalized.includes("timed out") ||
normalized.includes("offline") ||
normalized.includes("not connected")
? "The device that owns this conversation is offline or did not respond."
: message,
);
});
},
[
addNotify,
displayedConversationId,
displayedConversationWorkdir,
handleChangedFileReveal,
openWorkspaceEditorFile,
openWorkspaceFilePreview,
terminalProjectPathKey,
],
);
// RightDockPanel is memo'd: every callback handed to it must be stable or
// the memo boundary is void (see the panel-side context useMemo).
const handleChatTranscriptWidthChange = useCallback(
Expand Down Expand Up @@ -4765,6 +4844,7 @@ export default function GatewayApp() {
showUsage={isAgentDevExecutionMode}
usageContextWindow={currentModelContextWindow}
workspaceRoot={displayedConversationWorkdir}
onOpenFileLink={handleOpenChatFileLink}
gitClient={gitClient}
onLoadUploadedImagePreview={handleLoadUploadedImagePreview}
onResendFromEdit={handleResendFromEdit}
Expand Down
8 changes: 8 additions & 0 deletions crates/agent-gateway/web/src/components/GatewayTranscript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { ImagePreview, type ImagePreviewSlide } from "@/components/chat/ImagePreview";
import { Markdown } from "@/components/Markdown";
import { useLocale } from "@/i18n/LocaleContext";
import type { ChatFileLink } from "@/lib/chat/chatFileLinks";
import { normalizeLiveToolStatus, VIBING_STATUS } from "@/lib/chat/chatPageHelpers";
import type { HistoryMessageRef } from "@/lib/chat/conversationState";
import { getRoundText, getRoundToolTrace } from "@/lib/chat/uiMessages";
Expand Down Expand Up @@ -114,6 +115,7 @@ type GatewayTranscriptProps = {
usageContextWindow?: number;
workspaceRoot?: string;
gitClient?: GitClient | null;
onOpenFileLink?: (link: ChatFileLink) => void;
onLoadUploadedImagePreview?: UploadedImagePreviewLoader;
onResendFromEdit?: (
messageRef: HistoryMessageRef,
Expand Down Expand Up @@ -1193,6 +1195,7 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
usageContextWindow?: number;
workspaceRoot?: string;
gitClient?: GitClient | null;
onOpenFileLink?: (link: ChatFileLink) => void;
onLoadUploadedImagePreview?: UploadedImagePreviewLoader;
onResendFromEdit?: (
messageRef: HistoryMessageRef,
Expand Down Expand Up @@ -1226,6 +1229,7 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
usageContextWindow,
workspaceRoot,
gitClient,
onOpenFileLink,
onLoadUploadedImagePreview,
onResendFromEdit,
onBranchConversation,
Expand Down Expand Up @@ -1721,6 +1725,8 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
renderMode={rowRenderMode(row)}
readOnly={readOnly}
redactToolContent={redactToolContent}
workdir={workspaceRoot}
onOpenFileLink={onOpenFileLink}
/>
{shouldShowLiveStatus ? <LiveStatusFooter status={liveStatusText} /> : null}
{isLatestLiveStreaming &&
Expand Down Expand Up @@ -1809,6 +1815,7 @@ export function GatewayTranscript({
usageContextWindow,
workspaceRoot,
gitClient,
onOpenFileLink,
onLoadUploadedImagePreview,
onResendFromEdit,
onBranchConversation,
Expand Down Expand Up @@ -1888,6 +1895,7 @@ export function GatewayTranscript({
usageContextWindow={usageContextWindow}
workspaceRoot={workspaceRoot}
gitClient={gitClient}
onOpenFileLink={onOpenFileLink}
onLoadUploadedImagePreview={onLoadUploadedImagePreview}
onResendFromEdit={onResendFromEdit}
onBranchConversation={onBranchConversation}
Expand Down
Loading
Loading