From fa983fe75f3ca87d2bb34fb9aa2a77b563c1f34f Mon Sep 17 00:00:00 2001 From: singchia Date: Sun, 27 Sep 2026 23:45:18 +0800 Subject: [PATCH 01/16] feat(agent): enhance applications, session workspace, and file transfers Add Agent application bindings, persistent transcript navigation, Mermaid rendering, scoped file and image transfers, approval interactions, and responsive workspace controls. Preserve full-page mode across reloads and extend regression coverage for Agent workflows and access UI consistency. --- api/agent_applications.proto | 27 + api/edge_agent.proto | 25 +- ...6010000_create_agent_applications.down.sql | 5 + ...926010000_create_agent_applications.up.sql | 24 + docs/architecture/edge-agent-files.md | 38 + docs/architecture/edge-agent-history.md | 9 +- .../agent/adapters/codex/attachments_test.go | 40 + .../agent/adapters/codex/permissions_test.go | 31 + pkg/edge/agent/adapters/codex/session.go | 26 +- pkg/edge/agent/bridge/activity.go | 92 +- pkg/edge/agent/bridge/activity_matrix_test.go | 58 ++ .../bridge/approval_interactions_test.go | 132 +++ pkg/edge/agent/bridge/attachments.go | 121 +++ pkg/edge/agent/bridge/bridge.go | 68 +- pkg/edge/agent/bridge/files.go | 336 +++++++ pkg/edge/agent/bridge/files_other.go | 7 + pkg/edge/agent/bridge/files_test.go | 148 ++++ pkg/edge/agent/bridge/files_unix.go | 14 + pkg/edge/agent/bridge/permissions.go | 108 ++- pkg/edge/agent/bridge/plan_test.go | 59 ++ pkg/edge/agent/runtime/runtime.go | 11 + .../manager/controlplane/agent_access.go | 16 +- .../manager/controlplane/agent_application.go | 121 +++ .../controlplane/agent_application_test.go | 78 ++ .../manager/controlplane/edge_agent.go | 24 +- .../controlplane/edge_agent_files_test.go | 50 ++ .../controlplane/edge_agent_history.go | 2 +- .../controlplane/edge_agent_history_test.go | 9 + .../controlplane/edge_agent_transcript.go | 40 +- .../manager/web/agent_application_http.go | 94 ++ pkg/liaison/manager/web/edge_agent_http.go | 2 +- pkg/liaison/manager/web/web.go | 2 + pkg/liaison/repo/dao/dao.go | 9 + pkg/liaison/repo/dao/dao_agent_access.go | 40 +- pkg/liaison/repo/dao/dao_agent_access_test.go | 2 +- pkg/liaison/repo/dao/dao_agent_application.go | 125 +++ .../repo/dao/dao_agent_application_test.go | 131 +++ .../repo/dao/dao_edge_agent_history_page.go | 32 + .../repo/dao/dao_edge_agent_history_test.go | 46 +- pkg/liaison/repo/model/model_agent_access.go | 22 +- .../repo/model/model_agent_application.go | 18 + pkg/proto/edge_agent.go | 89 +- pkg/proto/edge_agent_files.go | 59 ++ pkg/proto/edge_agent_files_test.go | 33 + pkg/proto/edge_agent_test.go | 4 + web/e2e/access-empty.cjs | 3 + web/e2e/agent-applications.cjs | 44 + web/e2e/agent-applications.html | 1 + web/e2e/agent-applications.tsx | 5 + web/e2e/agent-files.cjs | 2 + web/e2e/agent-transcript.cjs | 52 ++ web/e2e/agent-transcript.html | 1 + web/e2e/agent-transcript.tsx | 14 + web/e2e/application-tabs.cjs | 38 + web/e2e/application-tabs.html | 1 + web/e2e/application-tabs.tsx | 5 + web/e2e/edge-agent.cjs | 276 +++++- web/e2e/llm-create.cjs | 2 +- web/e2e/mermaid.cjs | 64 ++ web/e2e/mermaid.html | 1 + web/e2e/mermaid.tsx | 12 + web/e2e/run-core.sh | 2 +- web/package.json | 1 + web/pnpm-lock.yaml | 827 +++++++++++++++++- .../AgentWorkspace/MermaidBlock.tsx | 92 ++ .../AgentWorkspace/MessageContent.tsx | 16 +- web/src/components/AgentWorkspace/index.less | 14 + .../components/AgentWorkspace/mermaidFrame.ts | 52 ++ web/src/components/icons/ProtocolIcon.tsx | 2 +- web/src/pages/App/index.tsx | 56 +- web/src/pages/EdgeAgent/AccessForm.tsx | 25 +- web/src/pages/EdgeAgent/Activity.tsx | 34 +- web/src/pages/EdgeAgent/Applications.tsx | 44 + web/src/pages/EdgeAgent/Files.tsx | 99 +++ web/src/pages/EdgeAgent/InputRequest.tsx | 4 +- web/src/pages/EdgeAgent/SessionDetails.tsx | 1 + web/src/pages/EdgeAgent/SessionResize.tsx | 26 + web/src/pages/EdgeAgent/SessionsWorkspace.tsx | 45 +- web/src/pages/EdgeAgent/SlashMenu.tsx | 21 +- web/src/pages/EdgeAgent/Transcript.tsx | 81 ++ web/src/pages/EdgeAgent/Workspace.tsx | 94 +- web/src/pages/EdgeAgent/index.less | 70 +- web/src/pages/EdgeAgent/index.tsx | 4 +- web/src/pages/EdgeAgent/interaction.less | 32 +- web/src/pages/Proxy/AccessEmptyState.tsx | 4 +- web/src/pages/Proxy/LLMEmptyState.tsx | 4 +- web/src/pages/Proxy/groups.less | 2 +- web/src/pages/Proxy/index.tsx | 8 +- web/src/services/edgeAgent.ts | 15 +- 89 files changed, 4352 insertions(+), 271 deletions(-) create mode 100644 api/agent_applications.proto create mode 100644 db/migrations/20260926010000_create_agent_applications.down.sql create mode 100644 db/migrations/20260926010000_create_agent_applications.up.sql create mode 100644 docs/architecture/edge-agent-files.md create mode 100644 pkg/edge/agent/adapters/codex/attachments_test.go create mode 100644 pkg/edge/agent/bridge/activity_matrix_test.go create mode 100644 pkg/edge/agent/bridge/approval_interactions_test.go create mode 100644 pkg/edge/agent/bridge/attachments.go create mode 100644 pkg/edge/agent/bridge/files.go create mode 100644 pkg/edge/agent/bridge/files_other.go create mode 100644 pkg/edge/agent/bridge/files_test.go create mode 100644 pkg/edge/agent/bridge/files_unix.go create mode 100644 pkg/edge/agent/bridge/plan_test.go create mode 100644 pkg/liaison/manager/controlplane/agent_application.go create mode 100644 pkg/liaison/manager/controlplane/agent_application_test.go create mode 100644 pkg/liaison/manager/controlplane/edge_agent_files_test.go create mode 100644 pkg/liaison/manager/web/agent_application_http.go create mode 100644 pkg/liaison/repo/dao/dao_agent_application.go create mode 100644 pkg/liaison/repo/dao/dao_agent_application_test.go create mode 100644 pkg/liaison/repo/model/model_agent_application.go create mode 100644 pkg/proto/edge_agent_files.go create mode 100644 pkg/proto/edge_agent_files_test.go create mode 100644 web/e2e/agent-applications.cjs create mode 100644 web/e2e/agent-applications.html create mode 100644 web/e2e/agent-applications.tsx create mode 100644 web/e2e/agent-files.cjs create mode 100644 web/e2e/agent-transcript.cjs create mode 100644 web/e2e/agent-transcript.html create mode 100644 web/e2e/agent-transcript.tsx create mode 100644 web/e2e/application-tabs.cjs create mode 100644 web/e2e/application-tabs.html create mode 100644 web/e2e/application-tabs.tsx create mode 100644 web/e2e/mermaid.cjs create mode 100644 web/e2e/mermaid.html create mode 100644 web/e2e/mermaid.tsx create mode 100644 web/src/components/AgentWorkspace/MermaidBlock.tsx create mode 100644 web/src/components/AgentWorkspace/mermaidFrame.ts create mode 100644 web/src/pages/EdgeAgent/Applications.tsx create mode 100644 web/src/pages/EdgeAgent/Files.tsx create mode 100644 web/src/pages/EdgeAgent/SessionResize.tsx create mode 100644 web/src/pages/EdgeAgent/Transcript.tsx diff --git a/api/agent_applications.proto b/api/agent_applications.proto new file mode 100644 index 00000000..5380917d --- /dev/null +++ b/api/agent_applications.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; +package liaison.agent_applications; +option go_package = "github.com/liaisonio/liaison/api/agent_applications;agent_applications"; +// Handwritten JSON transport, like edge_agent.proto. No generated service. +// GET /api/v1/agent-applications?page=1&page_size=20&edge_id=... +// POST /api/v1/agent-applications registers a discovered, owned installation. +// PUT /api/v1/agent-applications/{id} renames only; identity is immutable. +// DELETE /api/v1/agent-applications/{id} rejects referenced applications (409). +// Actor comes from authentication, never from the input. +message AgentApplicationInput { + string name = 1; + string kind = 2; + uint64 edge_id = 3; + string installation_id = 4; +} +message AgentApplication { + string id = 1; + string name = 2; + string kind = 3; + uint64 edge_id = 4; + string installation_id = 5; + int64 access_count = 6; +} +message AgentApplicationList { repeated AgentApplication items = 1; int64 total = 2; } +// Additive field for the existing handwritten AgentAccessInput JSON contract. +// application_id resolves the installation on the server. Supplied legacy +// kind/edge_id/installation_id fields must match, not override, that association. diff --git a/api/edge_agent.proto b/api/edge_agent.proto index bc6c1082..85085fd9 100644 --- a/api/edge_agent.proto +++ b/api/edge_agent.proto @@ -37,6 +37,19 @@ message EdgeAgentRequest { string input_id = 18; // answer: opaque live request ID, not a native RPC ID. repeated InputAnswer answers = 19; string history_before = 20; // transcript: exclusive window cursor, one archived round per page. + AgentFileOperation file = 21; // file_* actions only; bounded chunks, never model content. + repeated string attachments = 22; // send: session-local relative attachment paths, max 8. +} +// file_list, file_begin, file_write, file_commit, file_cancel, file_read. +// Transfers require own access + session + connector on every chunk. Uploads +// create unique files under .liaison-attachments/, never overwrite. +message AgentFileOperation { + string path = 1; string name = 2; string transfer_id = 3; + int64 offset = 4; int64 size = 5; bytes data = 6; +} +message AgentFile { + string name = 1; string path = 2; int64 size = 3; bool directory = 4; + string media_type = 5; } // Edge RPC name: edge_agent_v1. No raw RPC forwarding from HTTP clients. message EdgeAgentRPCRequest { @@ -59,7 +72,7 @@ message ResumeState { bool truncated = 9; } message Installation { string id = 1; string kind = 2; string path = 3; string source = 4; } -message Message { string role = 1; string text = 2; } +message Message { string role = 1; string text = 2; repeated AgentFile attachments = 3; } message EdgeAgentResult { uint32 version = 1; // ok, busy, unavailable, upgrade_required, not_found, unsupported_user, @@ -101,8 +114,16 @@ message EdgeAgentResult { repeated InputRequest input_requests = 35; // Live only; removed from persisted snapshots. uint64 window = 36; bool history_windowing = 37; + bool files_available = 38; + repeated AgentFile files = 39; + AgentFile file = 40; + string transfer_id = 41; + bytes file_data = 42; + int64 file_offset = 43; + bool file_done = 44; + bool files_upload_available = 45; } -message Approval { string id = 1; string command = 2; string directory = 3; string reason = 4; } +message Approval { string id = 1; string command = 2; string directory = 3; string reason = 4; string kind = 5; repeated FileChange changes = 6; } message AgentModel { string id = 1; string name = 2; } message SessionSummary { string session_id = 1; diff --git a/db/migrations/20260926010000_create_agent_applications.down.sql b/db/migrations/20260926010000_create_agent_applications.down.sql new file mode 100644 index 00000000..9247f3ac --- /dev/null +++ b/db/migrations/20260926010000_create_agent_applications.down.sql @@ -0,0 +1,5 @@ +-- Run only after returning to an older manager and backing up application metadata. +-- Access IDs, legacy installation fields and session history are preserved. +DROP INDEX IF EXISTS idx_agent_accesses_application_id; +ALTER TABLE agent_accesses DROP COLUMN application_id; +DROP TABLE IF EXISTS agent_applications; diff --git a/db/migrations/20260926010000_create_agent_applications.up.sql b/db/migrations/20260926010000_create_agent_applications.up.sql new file mode 100644 index 00000000..93d10d5a --- /dev/null +++ b/db/migrations/20260926010000_create_agent_applications.up.sql @@ -0,0 +1,24 @@ +-- Apply once using the migration ledger, before starting the new manager. +CREATE TABLE IF NOT EXISTS agent_applications ( + id TEXT PRIMARY KEY, + owner_id INTEGER NOT NULL, + edge_id INTEGER NOT NULL, + kind TEXT NOT NULL, + installation_id TEXT NOT NULL, + name TEXT NOT NULL, + created_at DATETIME, + updated_at DATETIME +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_installation ON agent_applications(owner_id, edge_id, kind, installation_id); +ALTER TABLE agent_accesses ADD COLUMN `application_id` TEXT DEFAULT ""; +CREATE INDEX IF NOT EXISTS idx_agent_accesses_application_id ON agent_accesses(application_id); +INSERT INTO agent_applications (id, owner_id, edge_id, kind, installation_id, name, created_at, updated_at) +SELECT lower(hex(randomblob(16))), owner_id, edge_id, kind, installation_id, 'Codex', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM agent_accesses +GROUP BY owner_id, edge_id, kind, installation_id +ON CONFLICT(owner_id, edge_id, kind, installation_id) DO NOTHING; +UPDATE agent_accesses SET application_id = ( + SELECT id FROM agent_applications a WHERE a.owner_id = agent_accesses.owner_id + AND a.edge_id = agent_accesses.edge_id AND a.kind = agent_accesses.kind + AND a.installation_id = agent_accesses.installation_id +) WHERE application_id IS NULL OR application_id = ''; diff --git a/docs/architecture/edge-agent-files.md b/docs/architecture/edge-agent-files.md new file mode 100644 index 00000000..93c92b33 --- /dev/null +++ b/docs/architecture/edge-agent-files.md @@ -0,0 +1,38 @@ +# Agent session files + +The browser transfers files through Manager and the authenticated Edge RPC channel. +Codex does not transport file bytes. Each chunk reauthorizes the user, saved access, +connector ownership and file feature permissions. Edge independently verifies its +durable user/access/session binding and saved project. + +## Boundaries and limits + +- Content access is restricted to the session's working directory using `os.Root`. + Directory discovery's broader home-directory scope does not grant file access. +- Uploads create unique files in `.liaison-attachments//`, mode 0600. + They never replace existing project files. Removing a composer attachment only + detaches it from the pending message; it does not delete the uploaded file. +- Each file is limited to 20 MiB; each message to eight attachments. Native image + inputs support PNG, JPEG and GIF, up to 4 MiB and 40 million pixels per image. +- Transfers use 128 KiB chunks, with exact offsets and at most eight live transfers + per Edge. Abandoned transfers expire after two minutes. Partial uploads are hidden + and cannot be downloaded; commit publishes the complete file without overwrite. +- Downloads hold an open file descriptor and reject files changed during transfer. + They are saved as attachments, never rendered as HTML or executed. +- Image content is validated and copied into an Edge-owned temporary snapshot before + passing `localImage` to Codex. These exact snapshots are removed on the next turn + or session close. Original uploads remain in the project. +- Manager persists attachment names, paths and sizes with encrypted conversation + history. It does not persist transfer chunks or image bytes in SQLite. + +## UI and compatibility + +The composer supports file selection, image selection, file drop and image paste. +Uploads have progress/cancel, local image previews and removable attachment chips. +Project files can be browsed/downloaded from the session header; sent attachment +chips also provide download actions. File content requires an online connector. + +The optional `files_available` / `files_upload_available` capabilities gate controls. +Both Manager and Edge must be upgraded; older connectors remain usable for chat +without file controls. Native model/configuration selection is unchanged. The current +Agent runtime supports macOS and Linux; this does not add Windows Agent support. diff --git a/docs/architecture/edge-agent-history.md b/docs/architecture/edge-agent-history.md index 7738bf19..7b0fbb1f 100644 --- a/docs/architecture/edge-agent-history.md +++ b/docs/architecture/edge-agent-history.md @@ -19,10 +19,15 @@ Status: implementation of the user-approved server-side history proposal. Before a new turn, Manager archives the completed display window in a separate encrypted row, then sends an internal revision-checked reset acknowledgement to Edge. The browser cannot supply that acknowledgement. If archiving fails, the new turn is not sent and the previous window remains intact. Concurrent sends cannot reset a newer revision. Existing snapshots are window zero; old Edges continue the legacy protocol until upgraded. -The current round remains bounded (recent messages and activities, explicitly marked truncation) but never stops native execution because of display limits. Previous rounds are loaded one at a time using an exclusive window cursor. Pages have the same owner/access/connector/session scope, encryption, deletion semantics and no TTL as the session. A single extremely long round is not a lossless transcript: its display may be truncated; the native Codex history remains on the device. Rollback retains the additive page table. +The current round remains bounded (recent messages and activities, explicitly marked truncation) but never stops native execution because of display limits. The UI shows a continuous transcript: initially up to 19 archived rounds plus the live round, then prepends up to 20 earlier rounds on demand without replacing visible content. Completed execution details remain collapsed by default. Only the live round is refreshed during streaming. + +The `transcript` action accepts an optional `history_limit` (1–20), with the existing exclusive `history_before` cursor. Batch replies contain `history_pages` in descending window order and an exclusive `history_before` cursor for the next request (absent at the start of history). A streamed composite-index query caps encrypted payloads below 2 MiB per request; oversized batches return fewer complete rounds, never skip or split a round. Authorization and deletion are rechecked after reading. Omitting the limit preserves the legacy single-round response. This changes no schema, retention, or native context. Pages have the same owner/access/connector/session scope, encryption, deletion semantics and no TTL as the session. A single extremely long round is not a lossless transcript: its display may be truncated; the native Codex history remains on the device. Rollback retains the additive page table. ### Structured interaction and execution details +- Project documented App Server `turn/plan/updated` events into bounded plan steps with live completion state. Proposed `plan` items use the safe Markdown renderer. Show only safe names/queries for search and tool calls, not tool arguments, raw results, raw reasoning, or remote images. See the [official event protocol](https://developers.openai.com/codex/app-server). +- Keep conversation width constrained. Long commands, paths and tool names must not enlarge the transcript; wide code and diffs scroll only inside their own containers. + - Native `item/tool/requestUserInput` requests render single-choice options or free text (password inputs for secret questions). Liaison does not infer questions from assistant Markdown. Support at most three questions and eight options per question; unsupported requests fail closed. - Answers use owner/access-scoped, opaque, single-use IDs and the original native request ID. Turn completion, shutdown and `serverRequest/resolved` invalidate pending requests. Never archive live request IDs or replay them on resume. - Display history keeps questions and answers, but masks secret answers. This does not redact secrets subsequently echoed by the model or a command; execution output must be treated as sensitive owner-only history. @@ -34,6 +39,8 @@ The current round remains bounded (recent messages and activities, explicitly ma - Saved-access turns have no three-minute wall-clock deadline. The 30-minute idle lease and 24-hour process age reclaim only idle runtimes; neither deletes history. Temporary discovery previews retain their short lease. - Sessions start and resume read-only. A user may select workspace-write while idle; the next native turn permits writes under that session's canonical working directory, keeps network access disabled, and excludes ambient temporary directories. Selection is session-local and resets to read-only on native resume. It never edits Codex configuration. - Native command approval requests are shown to the owner with their exact command, working directory and reason. Only accept-once and decline are supported. No execution-policy amendments, persistent grants, generic JSON-RPC forwarding, or automatic approval. +- Native file-change requests can be approved once or declined when their current item contains complete, non-truncated text diffs and all source/destination paths remain inside the canonical project. Missing details, unsupported change kinds, missing parent directories, symlink escapes, and `grantRoot` requests fail closed. Pending diffs are live-only, like command approvals. +- Question cards have an explicit Stop turn action; cancellation interrupts the native turn rather than fabricating an answer. Activity and command details open during execution and collapse on completion unless the user explicitly changed their expansion state. Prior rounds do not reopen when a new round runs. - Approval IDs are opaque, live, turn-bound and single-use. Owner/access authorization runs on every action. Completed/closed turns clear pending requests; encrypted history does not store them. Unknown, oversized, network-only, stdin, file-root and permission-profile requests fail closed until their scopes have dedicated UI support. - Run race tests for control plane, DAO, Edge bridge and protocol; cover stale writes, deletion tombstones, owner/access/connector isolation and non-renewing background snapshots. diff --git a/pkg/edge/agent/adapters/codex/attachments_test.go b/pkg/edge/agent/adapters/codex/attachments_test.go new file mode 100644 index 00000000..ae732a93 --- /dev/null +++ b/pkg/edge/agent/adapters/codex/attachments_test.go @@ -0,0 +1,40 @@ +package codex + +import ( + "context" + "encoding/json" + "net" + "testing" + "time" + + "github.com/liaisonio/liaison/pkg/edge/agent/rpc" + agentruntime "github.com/liaisonio/liaison/pkg/edge/agent/runtime" + "github.com/stretchr/testify/require" +) + +func TestAttachmentsUseNativeImageInputAndKeepModel(t *testing.T) { + client, server := net.Pipe() + c := rpc.New(client, client) + defer c.Close() + defer server.Close() + received := make(chan map[string]json.RawMessage, 1) + done := make(chan error, 1) + go func() { + var request map[string]json.RawMessage + if err := json.NewDecoder(server).Decode(&request); err != nil { + done <- err + return + } + received <- request + done <- json.NewEncoder(server).Encode(map[string]any{"id": request["id"], "result": map[string]any{"turn": map[string]string{"id": "turn-image"}}}) + }() + s := &Session{client: c, threads: map[string]string{"owned": ""}} + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + turn, err := s.SendAttachments(ctx, "owned", "Explain the image", "", "native-model", []agentruntime.Attachment{{Path: "/project/image.png", ImagePath: "/edge/private-image"}}) + require.NoError(t, err) + require.Equal(t, "turn-image", turn) + require.NoError(t, <-done) + req := <-received + require.JSONEq(t, `{"threadId":"owned","input":[{"type":"text","text":"Explain the image"},{"type":"localImage","path":"/edge/private-image"}],"model":"native-model","approvalPolicy":"on-request","sandboxPolicy":{"type":"readOnly"}}`, string(req["params"])) +} diff --git a/pkg/edge/agent/adapters/codex/permissions_test.go b/pkg/edge/agent/adapters/codex/permissions_test.go index 09b7598e..9d0bb871 100644 --- a/pkg/edge/agent/adapters/codex/permissions_test.go +++ b/pkg/edge/agent/adapters/codex/permissions_test.go @@ -74,3 +74,34 @@ func TestInputAnswerPreservesNativeRequestID(t *testing.T) { t.Fatal("input answer not delivered") } } + +func TestFileApprovalPreservesNativeRequestID(t *testing.T) { + client, server := net.Pipe() + c := rpc.New(client, client) + defer c.Close() + defer server.Close() + received := make(chan json.RawMessage, 1) + go func() { + var raw json.RawMessage + if json.NewDecoder(server).Decode(&raw) == nil { + received <- raw + } + }() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + s := &Session{client: c} + require.Error(t, s.ApproveFileChange(ctx, rpc.Message{ID: json.RawMessage(`1`), Method: "item/permissions/requestApproval"})) + require.NoError(t, s.ApproveFileChange(ctx, rpc.Message{ID: json.RawMessage(`"file-1"`), Method: "item/fileChange/requestApproval"})) + select { + case raw := <-received: + var response struct { + ID string `json:"id"` + Result json.RawMessage `json:"result"` + } + require.NoError(t, json.Unmarshal(raw, &response)) + require.Equal(t, "file-1", response.ID) + require.JSONEq(t, `{"decision":"accept"}`, string(response.Result)) + case <-ctx.Done(): + t.Fatal("approval not delivered") + } +} diff --git a/pkg/edge/agent/adapters/codex/session.go b/pkg/edge/agent/adapters/codex/session.go index 09723845..99c8cb1e 100644 --- a/pkg/edge/agent/adapters/codex/session.go +++ b/pkg/edge/agent/adapters/codex/session.go @@ -246,6 +246,16 @@ func (s *Session) SendSkill(ctx context.Context, threadID, text, skillID string) return s.send(ctx, threadID, text, skillID) } func (s *Session) send(ctx context.Context, threadID, text, skillID string, selectedModel ...string) (string, error) { + model := "" + if len(selectedModel) > 0 { + model = selectedModel[0] + } + return s.sendAttachments(ctx, threadID, text, skillID, model, nil) +} +func (s *Session) SendAttachments(ctx context.Context, threadID, text, skillID, model string, attachments []agentruntime.Attachment) (string, error) { + return s.sendAttachments(ctx, threadID, text, skillID, model, attachments) +} +func (s *Session) sendAttachments(ctx context.Context, threadID, text, skillID, model string, attachments []agentruntime.Attachment) (string, error) { s.mu.Lock() _, owned := s.threads[threadID] skill, known := s.skills[skillID] @@ -258,6 +268,11 @@ func (s *Session) send(ctx context.Context, threadID, text, skillID string, sele return "", errors.New("invalid agent message") } inputs := []map[string]string{{"type": "text", "text": text}} + for _, a := range attachments { + if a.ImagePath != "" { + inputs = append(inputs, map[string]string{"type": "localImage", "path": a.ImagePath}) + } + } if skillID != "" { if !known { return "", errors.New("skill unavailable") @@ -270,8 +285,8 @@ func (s *Session) send(ctx context.Context, threadID, text, skillID string, sele if mode == "workspace-write" { params["sandboxPolicy"] = map[string]any{"type": "workspaceWrite", "writableRoots": []string{s.directory}, "networkAccess": false, "excludeTmpdirEnvVar": true, "excludeSlashTmp": true} } - if len(selectedModel) > 0 && selectedModel[0] != "" { - params["model"] = selectedModel[0] + if model != "" { + params["model"] = model } data, err := s.client.Call(ctx, "turn/start", params) if err != nil { @@ -319,6 +334,13 @@ func (s *Session) ApproveCommand(ctx context.Context, message rpc.Message) error } // Unsupported permission requests fail closed rather than broadening access. +func (s *Session) ApproveFileChange(ctx context.Context, message rpc.Message) error { + if len(message.ID) == 0 || message.Method != "item/fileChange/requestApproval" { + return errors.New("unsupported approval") + } + return s.client.Respond(ctx, message.ID, map[string]string{"decision": "accept"}) +} + func (s *Session) AnswerInput(ctx context.Context, message rpc.Message, answers map[string][]string) error { if len(message.ID) == 0 || message.Method != "item/tool/requestUserInput" { return errors.New("unsupported input request") diff --git a/pkg/edge/agent/bridge/activity.go b/pkg/edge/agent/bridge/activity.go index 04bfe75c..805fb0c6 100644 --- a/pkg/edge/agent/bridge/activity.go +++ b/pkg/edge/agent/bridge/activity.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/hex" "github.com/liaisonio/liaison/pkg/proto" + "net/url" "strings" "unicode" ) @@ -14,7 +15,7 @@ func (s *session) recordActivityLocked(id, kind, status string, duration int64, return } switch kind { - case "commandExecution", "fileChange", "webSearch", "mcpToolCall", "dynamicToolCall", "reasoning", "contextCompaction", "userInput", "turnDiff": + case "commandExecution", "fileChange", "webSearch", "mcpToolCall", "dynamicToolCall", "reasoning", "contextCompaction", "userInput", "turnDiff", "turnPlan", "plan", "imageView", "enteredReviewMode", "exitedReviewMode": default: return } @@ -70,6 +71,18 @@ const maxActivityDetails = 96 << 10 const maxActivityText = 16 << 10 type nativeActivity struct { + Text string `json:"text"` + Query string `json:"query"` + Tool string `json:"tool"` + Server string `json:"server"` + Path string `json:"path"` + Action struct { + Type string `json:"type"` + Query string `json:"query"` + Queries []string `json:"queries"` + URL string `json:"url"` + Pattern string `json:"pattern"` + } `json:"action"` ID string `json:"id"` Type string `json:"type"` Status string `json:"status"` @@ -93,7 +106,10 @@ func activityKey(id string) string { return hex.EncodeToString(sum[:16]) } func activitySize(a proto.AgentActivity) int { - n := len(a.Command) + len(a.Directory) + len(a.Output) + n := len(a.Command) + len(a.Directory) + len(a.Output) + len(a.Label) + for _, step := range a.Plan { + n += len(step.Step) + len(step.Status) + } for _, c := range a.Changes { n += len(c.Path) + len(c.Diff) + len(c.MovePath) } @@ -165,11 +181,43 @@ func (s *session) recordActivityDetailsLocked(item nativeActivity) { if i < 0 { return } + a := s.activities[i] + budget := s.activityBudgetLocked(i) if item.Type != "commandExecution" && item.Type != "fileChange" { + // Metadata only: never forward tool arguments/results, raw reasoning, + // external image data, or provider errors into display history. + label := "" + switch item.Type { + case "webSearch": + label = item.Query + if label == "" { + label = item.Action.Query + } + if label == "" { + label = strings.Join(item.Action.Queries, " · ") + } + if label == "" { + if parsed, err := url.Parse(item.Action.URL); err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") { + parsed.User = nil + parsed.RawQuery = "" + parsed.Fragment = "" + label = parsed.String() + } + } + case "mcpToolCall", "dynamicToolCall": + label = strings.Trim(item.Server+" / "+item.Tool, " / ") + case "imageView": + label = item.Path + case "plan": + a.Output = boundedActivityText(item.Text, &budget, &a.Truncated) + default: + return + } + a.Label = boundedActivityText(label, &budget, &a.Truncated) + s.activities[i] = a + s.changedLocked() return } - a := s.activities[i] - budget := s.activityBudgetLocked(i) a.Truncated = false a.Command = boundedActivityText(item.Command, &budget, &a.Truncated) a.Directory = boundedActivityText(item.Cwd, &budget, &a.Truncated) @@ -186,6 +234,7 @@ func (s *session) recordActivityDetailsLocked(item nativeActivity) { break } if change.Kind.Type != "add" && change.Kind.Type != "delete" && change.Kind.Type != "update" { + a.Truncated = true continue } c := proto.AgentFileChange{Kind: change.Kind.Type} @@ -197,6 +246,41 @@ func (s *session) recordActivityDetailsLocked(item nativeActivity) { s.activities[i] = a s.changedLocked() } + +func (s *session) recordPlanLocked(turn, explanation string, steps []proto.AgentPlanStep) { + if s.access == "" { + return + } + id := "turn-plan:" + turn + s.recordActivityLocked(id, "turnPlan", "", 0, false) + i := s.activityIndexLocked(id) + if i < 0 { + return + } + a := s.activities[i] + budget := min(maxActivityText, s.activityBudgetLocked(i)) + a.Truncated = false + a.Output = boundedActivityText(explanation, &budget, &a.Truncated) + a.Plan = nil + a.Status = "completed" + for _, step := range steps { + if len(a.Plan) >= 32 || budget < 16 { + a.Truncated = true + break + } + if step.Status != "pending" && step.Status != "inProgress" && step.Status != "completed" { + continue + } + budget -= len(step.Status) + step.Step = boundedActivityText(step.Step, &budget, &a.Truncated) + a.Plan = append(a.Plan, step) + if step.Status != "completed" { + a.Status = "running" + } + } + s.activities[i] = a + s.changedLocked() +} func (s *session) recordActivityOutputLocked(id, text string, appendText bool) { if s.access == "" { return diff --git a/pkg/edge/agent/bridge/activity_matrix_test.go b/pkg/edge/agent/bridge/activity_matrix_test.go new file mode 100644 index 00000000..e69fe680 --- /dev/null +++ b/pkg/edge/agent/bridge/activity_matrix_test.go @@ -0,0 +1,58 @@ +package bridge + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/liaisonio/liaison/pkg/edge/agent/rpc" + "github.com/liaisonio/liaison/pkg/proto" + "github.com/stretchr/testify/require" +) + +func TestActivityKindsAndTerminalStates(t *testing.T) { + kinds := []string{"commandExecution", "fileChange", "webSearch", "mcpToolCall", "dynamicToolCall", "reasoning", "contextCompaction", "userInput", "turnDiff", "turnPlan", "plan", "imageView", "enteredReviewMode", "exitedReviewMode"} + for _, kind := range kinds { + for _, state := range []struct{ native, want string }{{"completed", "completed"}, {"failed", "failed"}, {"declined", "declined"}, {"interrupted", "ended"}, {"cancelled", "ended"}} { + t.Run(kind+"/"+state.native, func(t *testing.T) { + s := &session{access: strings.Repeat("a", 32)} + s.recordActivityLocked("item", kind, "", 0, false) + require.Equal(t, "running", s.activities[0].Status) + s.recordActivityLocked("item", kind, state.native, -10, true) + require.Equal(t, state.want, s.activities[0].Status) + require.Zero(t, s.activities[0].DurationMS) + s.recordActivityLocked("item", kind, "", 0, false) + require.Equal(t, state.want, s.activities[0].Status, "late start never revives a finished item") + }) + } + } + s := &session{} + s.recordActivityLocked("item", "unknownNativeType", "", 0, false) + require.Empty(t, s.activities) +} + +func TestResolvedApprovalAndInterruptedTurnCannotReplay(t *testing.T) { + b, a, req := setup(t) + req.AccessID = strings.Repeat("a", 32) + r := call(b, "alice", req) + q := proto.EdgeAgentRequest{Action: "send", AccessID: req.AccessID, SessionID: r.SessionID, Text: "inspect"} + call(b, "alice", q) + poll := proto.EdgeAgentRequest{Action: "poll", AccessID: req.AccessID, SessionID: r.SessionID} + approval := rpc.Message{ID: json.RawMessage(`"pending-1"`), Method: "item/commandExecution/requestApproval", Params: json.RawMessage(`{"threadId":"thread-owned","turnId":"turn-owned","command":"ls"}`)} + a.events <- approval + require.Eventually(t, func() bool { return len(call(b, "alice", poll).Approvals) == 1 }, time.Second, time.Millisecond) + approve := proto.EdgeAgentRequest{Action: "approve", AccessID: req.AccessID, SessionID: r.SessionID, ApprovalID: call(b, "alice", poll).Approvals[0].ID, Decision: "accept"} + a.events <- rpc.Message{Method: "serverRequest/resolved", Params: json.RawMessage(`{"threadId":"thread-owned","requestId":"pending-1"}`)} + require.Eventually(t, func() bool { return len(call(b, "alice", poll).Approvals) == 0 }, time.Second, time.Millisecond) + require.Equal(t, "invalid_request", call(b, "alice", approve).Status) + approval.ID = json.RawMessage(`"pending-2"`) + a.events <- approval + require.Eventually(t, func() bool { return len(call(b, "alice", poll).Approvals) == 1 }, time.Second, time.Millisecond) + approve.ApprovalID = call(b, "alice", poll).Approvals[0].ID + a.events <- rpc.Message{Method: "turn/completed", Params: json.RawMessage(`{"threadId":"thread-owned","turn":{"id":"turn-owned","status":"interrupted"}}`)} + require.Eventually(t, func() bool { return !call(b, "alice", poll).Running }, time.Second, time.Millisecond) + require.Empty(t, call(b, "alice", poll).Approvals) + require.Equal(t, "invalid_request", call(b, "alice", approve).Status) + require.Zero(t, a.approved) +} diff --git a/pkg/edge/agent/bridge/approval_interactions_test.go b/pkg/edge/agent/bridge/approval_interactions_test.go new file mode 100644 index 00000000..affa25f4 --- /dev/null +++ b/pkg/edge/agent/bridge/approval_interactions_test.go @@ -0,0 +1,132 @@ +package bridge + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/liaisonio/liaison/pkg/edge/agent/rpc" + "github.com/liaisonio/liaison/pkg/proto" + "github.com/stretchr/testify/require" +) + +type approvalAgent struct { + *fakeAgent + files int + rejects int + fail bool + snapshot func() +} + +func approvalFixture(t *testing.T) (*session, *approvalAgent) { + t.Helper() + root, err := filepath.EvalSymlinks(t.TempDir()) + require.NoError(t, err) + a := &approvalAgent{fakeAgent: &fakeAgent{events: make(chan rpc.Message), done: make(chan struct{})}} + s := &session{agent: a, project: root, access: strings.Repeat("a", 32), thread: "thread-owned", turn: "turn", running: true, status: "ok", changed: make(chan struct{})} + t.Cleanup(func() { require.NoError(t, a.Close()) }) + return s, a +} + +func (a *approvalAgent) ApproveFileChange(context.Context, rpc.Message) error { + a.files++ + if a.snapshot != nil { + a.snapshot() + } + if a.fail { + return errors.New("uncertain write") + } + return nil +} +func (a *approvalAgent) RejectRequest(context.Context, rpc.Message) error { a.rejects++; return nil } + +func TestFileApprovalBoundaries(t *testing.T) { + for _, test := range []struct { + name string + decision string + fail bool + }{{"accept", "accept", false}, {"decline", "decline", false}, {"uncertain write", "accept", true}} { + t.Run(test.name, func(t *testing.T) { + s, a := approvalFixture(t) + a.fail = test.fail + a.snapshot = func() { s.snapshot() } // Must not hold session lock while writing to the adapter. + s.recordActivityLocked("file", "fileChange", "", 0, false) + s.activities[0].Changes = []proto.AgentFileChange{{Path: "new.txt", Kind: "add", Diff: "+hello"}} + event := rpc.Message{ID: json.RawMessage(`"file-request"`), Method: "item/fileChange/requestApproval", Params: json.RawMessage(`{"threadId":"thread-owned","turnId":"turn","itemId":"file"}`)} + require.True(t, s.queueApproval(event)) + require.True(t, s.queueApproval(event)) + require.Len(t, s.snapshot().Approvals, 1) + q := proto.EdgeAgentRequest{Action: "approve", ApprovalID: s.snapshot().Approvals[0].ID, Decision: test.decision} + got := s.managePermissions(context.Background(), q) + if test.fail { + require.True(t, got.Closed) + } else { + require.Equal(t, "ok", got.Status) + } + require.Empty(t, s.snapshot().Approvals) + require.NotEqual(t, "ok", s.managePermissions(context.Background(), q).Status) + if test.decision == "accept" { + require.Equal(t, 1, a.files) + } else { + require.Equal(t, 1, a.rejects) + require.Zero(t, a.files) + } + }) + } +} + +func TestUnsafeApprovalRequestsStayClosed(t *testing.T) { + s, _ := approvalFixture(t) + s.recordActivityLocked("file", "fileChange", "", 0, false) + s.activities[0].Changes = []proto.AgentFileChange{{Path: "new.txt", Kind: "add", Diff: "+hello"}} + for _, params := range []string{ + `{"threadId":"foreign","turnId":"turn","itemId":"file"}`, + `{"threadId":"thread-owned","turnId":"old","itemId":"file"}`, + `{"threadId":"thread-owned","turnId":"turn","itemId":"missing"}`, + `{"threadId":"thread-owned","turnId":"turn","itemId":"file","grantRoot":"/"}`, + } { + require.False(t, s.queueApproval(rpc.Message{ID: json.RawMessage(`1`), Method: "item/fileChange/requestApproval", Params: json.RawMessage(params)})) + } + valid := rpc.Message{ID: json.RawMessage(`1`), Method: "item/fileChange/requestApproval", Params: json.RawMessage(`{"threadId":"thread-owned","turnId":"turn","itemId":"file"}`)} + s.activities[0].Truncated = true + require.False(t, s.queueApproval(valid)) + s.activities[0].Truncated = false + s.activities[0].Changes[0].Diff = "" + require.False(t, s.queueApproval(valid)) + s.activities[0].Changes[0].Diff = "+hello" + s.activities[0].Changes[0].Path = "../outside" + require.False(t, s.queueApproval(valid)) + for _, extra := range []string{`"networkApprovalContext":{}`, `"additionalPermissions":{}`} { + require.False(t, s.queueApproval(rpc.Message{ID: json.RawMessage(`1`), Method: "item/commandExecution/requestApproval", Params: json.RawMessage(`{"threadId":"thread-owned","turnId":"turn","command":"ls",` + extra + `}`)})) + } + require.Empty(t, s.snapshot().Approvals) +} + +func TestApprovalPathContainment(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + require.NoError(t, err) + outside := t.TempDir() + require.NoError(t, os.Symlink(outside, filepath.Join(root, "link"))) + require.NoError(t, os.Symlink(filepath.Join(outside, "missing"), filepath.Join(root, "dangling"))) + for _, path := range []string{"", ".", "..", "../outside", "link/file", "dangling", "missing-parent/file", outside} { + require.False(t, approvalPathInProject(root, path), path) + } + require.True(t, approvalPathInProject(root, "new.txt")) + require.True(t, approvalPathInProject(root, filepath.Join(root, "new.txt"))) +} + +func TestApprovalDisplayBudgetNeverTruncatesGrantDetails(t *testing.T) { + s, _ := approvalFixture(t) + s.recordActivityLocked("file", "fileChange", "", 0, false) + s.activities[0].Changes = []proto.AgentFileChange{{Path: "new.txt", Kind: "add", Diff: strings.Repeat("<", 16<<10)}} + event := rpc.Message{ID: json.RawMessage(`1`), Method: "item/fileChange/requestApproval", Params: json.RawMessage(`{"threadId":"thread-owned","turnId":"turn","itemId":"file"}`)} + require.True(t, s.queueApproval(event)) + event.ID = json.RawMessage(`2`) + require.False(t, s.queueApproval(event), "escaped JSON must fit the pending-request budget") + require.Len(t, s.snapshot().Approvals, 1) + require.Equal(t, s.activities[0].Changes[0].Diff, s.snapshot().Approvals[0].Changes[0].Diff) +} diff --git a/pkg/edge/agent/bridge/attachments.go b/pkg/edge/agent/bridge/attachments.go new file mode 100644 index 00000000..f5ceb7bb --- /dev/null +++ b/pkg/edge/agent/bridge/attachments.go @@ -0,0 +1,121 @@ +package bridge + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "os" + "path/filepath" + "strings" + + agentruntime "github.com/liaisonio/liaison/pkg/edge/agent/runtime" + "github.com/liaisonio/liaison/pkg/proto" +) + +func removeImageInputs(paths []string) { + for _, p := range paths { + _ = os.Remove(p) /* Exact Edge-created temporary snapshot, never a project path. */ + } +} +func isImageName(name string) bool { + switch strings.ToLower(filepath.Ext(name)) { + case ".png", ".jpg", ".jpeg", ".gif": + return true + } + return false +} +func readImage(reader io.Reader) ([]byte, string, error) { + data, err := io.ReadAll(io.LimitReader(reader, (4<<20)+1)) + if err != nil || len(data) > 4<<20 { + return nil, "", errors.New("invalid image") + } + config, format, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil || config.Width <= 0 || config.Height <= 0 || int64(config.Width)*int64(config.Height) > 40_000_000 { + return nil, "", errors.New("invalid image") + } + return data, "image/" + format, nil +} +func (b *Bridge) prepareAttachments(ctx context.Context, s *session, paths []string) ([]proto.AgentFile, []agentruntime.Attachment, []string, error) { + if len(paths) == 0 { + return nil, nil, nil, nil + } + root, err := os.OpenRoot(s.project) + if err != nil { + return nil, nil, nil, err + } + defer root.Close() + var display []proto.AgentFile + var inputs []agentruntime.Attachment + var temporary []string + success := false + defer func() { + if !success { + removeImageInputs(temporary) + } + }() + for _, p := range paths { + clean, ok := filePath(p) + dir := filepath.Join(".liaison-attachments", s.id) + if !ok || filepath.Dir(clean) != dir || strings.HasPrefix(filepath.Base(clean), ".") || ctx.Err() != nil { + return nil, nil, nil, errors.New("invalid attachment") + } + file, err := openAgentFile(root, clean) + if err != nil { + return nil, nil, nil, err + } + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() || info.Size() > proto.AgentFileLimit { + file.Close() + return nil, nil, nil, errors.New("invalid attachment") + } + item := proto.AgentFile{Name: attachmentName(clean), Path: filepath.ToSlash(clean), Size: info.Size()} + input := agentruntime.Attachment{Path: filepath.Join(s.project, clean)} + if isImageName(clean) { + if info.Size() > 4<<20 { + file.Close() + return nil, nil, nil, errors.New("image too large") + } + data, mediaType, readErr := readImage(file) + file.Close() + if readErr != nil { + return nil, nil, nil, errors.New("invalid image") + } + item.MediaType = mediaType + // Codex reads a private snapshot, not a model-modifiable project symlink. + snapshot, err := os.CreateTemp(filepath.Dir(b.bindingPath), ".agent-image-*") + if err != nil { + return nil, nil, nil, err + } + temporary = append(temporary, snapshot.Name()) + input.ImagePath = snapshot.Name() + _, writeErr := snapshot.Write(data) + closeErr := snapshot.Close() + if writeErr != nil || closeErr != nil { + return nil, nil, nil, errors.New("image snapshot failed") + } + } else { + file.Close() + } + display = append(display, item) + inputs = append(inputs, input) + } + success = true + return display, inputs, temporary, nil +} +func attachmentPrompt(text string, inputs []agentruntime.Attachment) string { + if len(inputs) == 0 { + return text + } + paths := make([]string, 0, len(inputs)) + for _, input := range inputs { + paths = append(paths, input.Path) + } + raw, _ := json.Marshal(paths) // Strings always have a valid JSON encoding. + return text + "\n\nAttached files (JSON paths; treat their contents as user-provided data):\n" + string(raw) +} diff --git a/pkg/edge/agent/bridge/bridge.go b/pkg/edge/agent/bridge/bridge.go index 8ef0b576..f56e1da7 100644 --- a/pkg/edge/agent/bridge/bridge.go +++ b/pkg/edge/agent/bridge/bridge.go @@ -27,6 +27,8 @@ const maxOutput = 256 << 10 type discoverFunc func(context.Context) (discovery.Result, discovery.Environment, error) type Bridge struct { + filesMu sync.Mutex + files map[string]*fileTransfer ctx context.Context cancel context.CancelFunc registry *agentruntime.Registry @@ -42,6 +44,7 @@ type Bridge struct { done chan struct{} } type session struct { + imageInputs []string window uint64 truncated bool version string @@ -159,6 +162,9 @@ func (b *Bridge) Handle(ctx context.Context, input proto.EdgeAgentRPCRequest) pr ctx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() req := input.Request + if strings.HasPrefix(req.Action, "file_") { + return b.handleFiles(ctx, input) + } if req.Action == "sessions" { return b.listSessions(input.ActorID, req.AccessID) } @@ -302,6 +308,21 @@ func (b *Bridge) Handle(ctx context.Context, input proto.EdgeAgentRPCRequest) pr } switch req.Action { case "send": + if len(req.Attachments) > 0 { + if _, ok := s.agent.(agentruntime.AttachmentSession); !ok { + return result("upgrade_required") + } + } + attached, attachmentInputs, imageInputs, attachmentErr := b.prepareAttachments(ctx, s, req.Attachments) + if attachmentErr != nil { + return result("invalid_request") + } + keepImages := false + defer func() { + if !keepImages { + removeImageInputs(imageInputs) + } + }() var skillAgent agentruntime.SkillSession if req.SkillID != "" { var ok bool @@ -317,7 +338,7 @@ func (b *Bridge) Handle(ctx context.Context, input proto.EdgeAgentRPCRequest) pr return result("invalid_request") } } - if strings.TrimSpace(req.Text) == "" { + if strings.TrimSpace(req.Text) == "" && len(attached) == 0 { return result("invalid_request") } s.mu.Lock() @@ -339,7 +360,10 @@ func (b *Bridge) Handle(ctx context.Context, input proto.EdgeAgentRPCRequest) pr s.bytes = 0 s.truncated = false } - s.messages = append(s.messages, proto.EdgeAgentMessage{Role: "user", Text: req.Text}, proto.EdgeAgentMessage{Role: "assistant"}) + previousImages := s.imageInputs + s.imageInputs = imageInputs + keepImages = true + s.messages = append(s.messages, proto.EdgeAgentMessage{Role: "user", Text: req.Text, Attachments: attached}, proto.EdgeAgentMessage{Role: "assistant"}) s.bytes += len(req.Text) s.boundDisplayLocked() s.running = true @@ -349,9 +373,12 @@ func (b *Bridge) Handle(ctx context.Context, input proto.EdgeAgentRPCRequest) pr s.changedLocked() selectedModel := s.selectedModel s.mu.Unlock() + removeImageInputs(previousImages) var turn string var err error - if capable, ok := s.agent.(agentruntime.ModelSession); ok && selectedModel != "" { + if len(attached) > 0 { + turn, err = s.agent.(agentruntime.AttachmentSession).SendAttachments(ctx, s.thread, attachmentPrompt(req.Text, attachmentInputs), req.SkillID, selectedModel, attachmentInputs) + } else if capable, ok := s.agent.(agentruntime.ModelSession); ok && selectedModel != "" { turn, err = capable.SendModel(ctx, s.thread, req.Text, req.SkillID, selectedModel) } else if skillAgent != nil { turn, err = skillAgent.SendSkill(ctx, s.thread, req.Text, req.SkillID) @@ -612,10 +639,12 @@ func (s *session) snapshot() proto.EdgeAgentResult { s.mu.Lock() defer s.mu.Unlock() approvals, permissions, mode := s.permissionSnapshotLocked() - return boundedSnapshot(proto.EdgeAgentResult{Window: s.window, HistoryWindowing: s.access != "", Truncated: s.truncated, InputRequests: s.inputSnapshotLocked(), Approvals: approvals, PermissionsAvailable: permissions, PermissionMode: mode, Version: 1, Status: s.status, SessionID: s.id, ThreadID: s.thread, AgentVersion: s.version, Model: s.model, Project: s.project, StartedAt: s.created.UTC().Format(time.RFC3339), Skills: append([]proto.AgentSkill{}, s.skills...), SkillsAvailable: s.skillsAvailable, Running: s.running, Closed: s.closed, Messages: append([]proto.EdgeAgentMessage{}, s.messages...), Revision: s.revision, Activities: append([]proto.AgentActivity{}, s.activities...), Title: s.displayTitleLocked(), SessionManagement: true, ModelsAvailable: s.modelsAvailable, Models: append([]proto.AgentModel{}, s.models...)}) + return boundedSnapshot(proto.EdgeAgentResult{FilesAvailable: s.access != "", Window: s.window, HistoryWindowing: s.access != "", Truncated: s.truncated, InputRequests: s.inputSnapshotLocked(), Approvals: approvals, PermissionsAvailable: permissions, PermissionMode: mode, Version: 1, Status: s.status, SessionID: s.id, ThreadID: s.thread, AgentVersion: s.version, Model: s.model, Project: s.project, StartedAt: s.created.UTC().Format(time.RFC3339), Skills: append([]proto.AgentSkill{}, s.skills...), SkillsAvailable: s.skillsAvailable, Running: s.running, Closed: s.closed, Messages: append([]proto.EdgeAgentMessage{}, s.messages...), Revision: s.revision, Activities: append([]proto.AgentActivity{}, s.activities...), Title: s.displayTitleLocked(), SessionManagement: true, ModelsAvailable: s.modelsAvailable, Models: append([]proto.AgentModel{}, s.models...)}) } func (s *session) stop(status string) { s.mu.Lock() + images := s.imageInputs + s.imageInputs = nil already := s.closed s.closed = true s.running = false @@ -639,6 +668,7 @@ func (s *session) stop(status string) { s.mu.Unlock() } } + removeImageInputs(images) } func (b *Bridge) consume(s *session) { defer func() { @@ -677,15 +707,17 @@ func (b *Bridge) consume(s *session) { continue } var p struct { - ThreadID string `json:"threadId"` - TurnID string `json:"turnId"` - Delta string `json:"delta"` - ItemID string `json:"itemId"` - ThreadName string `json:"threadName"` - Item nativeActivity `json:"item"` - Diff string `json:"diff"` - RequestID json.RawMessage `json:"requestId"` - Turn struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + Delta string `json:"delta"` + ItemID string `json:"itemId"` + ThreadName string `json:"threadName"` + Item nativeActivity `json:"item"` + Diff string `json:"diff"` + Explanation string `json:"explanation"` + Plan []proto.AgentPlanStep `json:"plan"` + RequestID json.RawMessage `json:"requestId"` + Turn struct { ID string `json:"id"` Status string `json:"status"` } `json:"turn"` @@ -734,6 +766,14 @@ func (b *Bridge) consume(s *session) { s.recordActivityLocked(id, "turnDiff", "", 0, false) s.recordActivityOutputLocked(id, p.Diff, false) } + case "item/plan/delta": + if s.running && (s.turn == "" || p.TurnID == s.turn) { + s.recordActivityOutputLocked(p.ItemID, p.Delta, true) + } + case "turn/plan/updated": + if s.running && p.TurnID != "" && (s.turn == "" || p.TurnID == s.turn) { + s.recordPlanLocked(p.TurnID, p.Explanation, p.Plan) + } case "turn/completed": if s.running && (s.turn == "" || s.turn == p.Turn.ID) { s.running = false @@ -756,6 +796,7 @@ func (b *Bridge) consume(s *session) { } func (b *Bridge) reap() { defer close(b.done) + defer b.expireFiles(time.Now(), true) defer func() { if recover() != nil { b.cancel() @@ -768,6 +809,7 @@ func (b *Bridge) reap() { case <-b.ctx.Done(): return case now := <-ticker.C: + b.expireFiles(now, false) b.mu.Lock() list := make([]*session, 0, len(b.sessions)) for _, s := range b.sessions { diff --git a/pkg/edge/agent/bridge/files.go b/pkg/edge/agent/bridge/files.go new file mode 100644 index 00000000..5311a597 --- /dev/null +++ b/pkg/edge/agent/bridge/files.go @@ -0,0 +1,336 @@ +package bridge + +import ( + "context" + "crypto/rand" + "encoding/hex" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/liaisonio/liaison/pkg/proto" +) + +type fileTransfer struct { + busy bool // Protected by Bridge.filesMu; one RPC owns the descriptor at a time. + scope string + file *os.File + root *os.Root + temporary, destination string + size, offset int64 + modified time.Time + touched time.Time +} + +func (t *fileTransfer) close() { + _ = t.file.Close() // Best-effort descriptor cleanup, never completes a failed upload. + if t.root != nil { + _ = t.root.Remove(t.temporary) // Only this transfer's exact temporary file. + _ = t.root.Close() + } +} +func (b *Bridge) expireFiles(now time.Time, all bool) { + b.filesMu.Lock() + var expired []*fileTransfer + for id, t := range b.files { + if !t.busy && (all || now.Sub(t.touched) > 2*time.Minute) { + delete(b.files, id) + expired = append(expired, t) + } + } + b.filesMu.Unlock() + for _, t := range expired { + t.close() + } +} +func (b *Bridge) removeTransfer(id string, t *fileTransfer) { + b.filesMu.Lock() + delete(b.files, id) + b.filesMu.Unlock() + t.close() +} +func (b *Bridge) releaseTransfer(id string, t *fileTransfer) { + b.filesMu.Lock() + if b.files[id] != t { + b.filesMu.Unlock() + return + } + t.busy = false + t.touched = time.Now() + closed := b.ctx.Err() != nil + if closed { + delete(b.files, id) + } + b.filesMu.Unlock() + if closed { + t.close() + } +} +func (b *Bridge) registerTransfer(id string, t *fileTransfer) bool { + b.filesMu.Lock() + defer b.filesMu.Unlock() + if len(b.files) >= 8 || b.ctx.Err() != nil { + return false + } + if b.files == nil { + b.files = make(map[string]*fileTransfer) + } + b.files[id] = t + return true +} +func fileToken() string { + var v [16]byte + if _, err := rand.Read(v[:]); err != nil { + return "" + } + return hex.EncodeToString(v[:]) +} +func filePath(p string) (string, bool) { + if p == "" { + p = "." + } + if strings.Contains(p, "\\") || !filepath.IsLocal(p) { + return "", false + } + for _, part := range strings.Split(filepath.ToSlash(p), "/") { + if strings.HasPrefix(part, ".upload-") { + return "", false + } + } + return filepath.Clean(p), true +} +func attachmentName(p string) string { + name := filepath.Base(p) + if len(name) > 33 && name[32] == '-' { + return name[33:] + } + return name +} + +// Content access is narrower than directory discovery: only the bound session +// project, never the entire OS user's home. os.Root pins traversal boundaries. +func (b *Bridge) handleFiles(ctx context.Context, input proto.EdgeAgentRPCRequest) proto.EdgeAgentResult { + req := input.Request + f := req.File + scope := bindingKey(input.ActorID, req.AccessID, req.SessionID) + b.mu.Lock() + binding, ok := b.bindings[scope] + b.mu.Unlock() + savedRoot, rootErr := filepath.EvalSymlinks(input.ProjectRoot) + if !ok || rootErr != nil || binding.AccessProject != savedRoot { + return result("not_found") + } + if ctx.Err() != nil { + return result("unavailable") + } + if f.TransferID != "" { + b.filesMu.Lock() + t := b.files[f.TransferID] + if t == nil || t.scope != scope { + b.filesMu.Unlock() + return result("not_found") + } + if t.busy { + b.filesMu.Unlock() + return result("busy") + } + t.busy = true + b.filesMu.Unlock() + defer b.releaseTransfer(f.TransferID, t) + fail := func() proto.EdgeAgentResult { + b.removeTransfer(f.TransferID, t) + return result("invalid_request") + } + switch req.Action { + case "file_cancel": + b.removeTransfer(f.TransferID, t) + return result("ok") + case "file_write": + if t.root == nil || f.Offset != t.offset || t.offset+int64(len(f.Data)) > t.size { + return fail() + } + n, err := t.file.Write(f.Data) + if err != nil || n != len(f.Data) { + return fail() + } + t.offset += int64(n) + out := result("ok") + out.FileOffset = t.offset + return out + case "file_commit": + if t.root == nil || t.offset != t.size { + return fail() + } + mediaType := "" + if isImageName(t.destination) { + if _, err := t.file.Seek(0, io.SeekStart); err != nil { + return fail() + } + _, kind, err := readImage(t.file) + if err != nil { + return fail() + } + mediaType = kind + } + if err := t.file.Sync(); err != nil { + return fail() + } + if err := t.file.Close(); err != nil { + return fail() + } + if ctx.Err() != nil { + return fail() + } + // Random destination, not a caller-supplied project file. Link is atomic and + // fails if a destination already exists, unlike a replacing rename. + if err := t.root.Link(t.temporary, t.destination); err != nil { + return fail() + } + out := result("ok") + out.File = &proto.AgentFile{Name: attachmentName(t.destination), Path: filepath.ToSlash(t.destination), Size: t.size, MediaType: mediaType} + b.removeTransfer(f.TransferID, t) + return out + case "file_read": + if t.root != nil { + return fail() + } + return b.readFileChunk(ctx, f.TransferID, t, f.Offset) + default: + return result("invalid_request") + } + } + root, err := os.OpenRoot(binding.Project) + if err != nil { + return result("unavailable") + } + defer root.Close() + p, ok := filePath(f.Path) + if !ok { + return result("invalid_request") + } + switch req.Action { + case "file_list": + dir, err := openAgentFile(root, p) + if err != nil { + return result("invalid_request") + } + defer dir.Close() + entries, err := dir.ReadDir(500) + if err != nil && err != io.EOF { + return result("invalid_request") + } + out := result("ok") + out.FilesAvailable = true + out.Directory = filepath.ToSlash(p) + out.Truncated = len(entries) == 500 + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".upload-") { + continue + } + if ctx.Err() != nil { + return result("unavailable") + } + if entry.Type()&os.ModeSymlink != 0 { + continue + } + info, err := entry.Info() + if err != nil || (!info.IsDir() && !info.Mode().IsRegular()) { + continue + } + out.Files = append(out.Files, proto.AgentFile{Name: entry.Name(), Path: filepath.ToSlash(filepath.Join(p, entry.Name())), Size: info.Size(), Directory: info.IsDir()}) + } + sort.Slice(out.Files, func(i, j int) bool { + if out.Files[i].Directory != out.Files[j].Directory { + return out.Files[i].Directory + } + return out.Files[i].Name < out.Files[j].Name + }) + return out + case "file_begin": + if isImageName(f.Name) && f.Size > 4<<20 { + return result("invalid_request") + } + id := fileToken() + if id == "" { + return result("unavailable") + } + dir := filepath.Join(".liaison-attachments", req.SessionID) + if err := root.MkdirAll(dir, 0700); err != nil { + return result("unavailable") + } + temporary := filepath.Join(dir, ".upload-"+id) + file, err := root.OpenFile(temporary, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0600) + if err != nil { + return result("unavailable") + } + // Own a second root descriptor for the transfer beyond this RPC. + owned, err := root.OpenRoot(".") + if err != nil { + file.Close() + root.Remove(temporary) + return result("unavailable") + } + t := &fileTransfer{scope: scope, file: file, root: owned, temporary: temporary, destination: filepath.Join(dir, id+"-"+f.Name), size: f.Size, touched: time.Now()} + if !b.registerTransfer(id, t) { + t.close() + return result("busy") + } + out := result("ok") + out.TransferID = id + return out + case "file_read": + file, err := openAgentFile(root, p) + if err != nil { + return result("invalid_request") + } + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() || info.Size() > proto.AgentFileLimit || f.Offset != 0 { + file.Close() + return result("invalid_request") + } + id := fileToken() + if id == "" { + file.Close() + return result("unavailable") + } + t := &fileTransfer{busy: true, scope: scope, file: file, size: info.Size(), modified: info.ModTime(), touched: time.Now()} + if !b.registerTransfer(id, t) { + t.close() + return result("busy") + } + defer b.releaseTransfer(id, t) + out := b.readFileChunk(ctx, id, t, 0) + out.File = &proto.AgentFile{Name: filepath.Base(p), Path: filepath.ToSlash(p), Size: info.Size()} + return out + } + return result("invalid_request") +} +func (b *Bridge) readFileChunk(ctx context.Context, id string, t *fileTransfer, offset int64) proto.EdgeAgentResult { + fail := func() proto.EdgeAgentResult { b.removeTransfer(id, t); return result("invalid_request") } + info, err := t.file.Stat() + if err != nil || info.Size() != t.size || !info.ModTime().Equal(t.modified) || offset != t.offset || ctx.Err() != nil { + return fail() + } + remaining := min(int64(proto.AgentFileChunk), t.size-t.offset) + data := make([]byte, int(remaining)) + if _, err := io.ReadFull(t.file, data); err != nil { + return fail() + } + after, err := t.file.Stat() + if err != nil || after.Size() != t.size || !after.ModTime().Equal(t.modified) { + return fail() + } + t.offset += remaining + out := result("ok") + out.FileData = data + out.FileOffset = t.offset + out.TransferID = id + out.FileDone = t.offset == t.size + if out.FileDone { + b.removeTransfer(id, t) + } + return out +} diff --git a/pkg/edge/agent/bridge/files_other.go b/pkg/edge/agent/bridge/files_other.go new file mode 100644 index 00000000..ec294fd8 --- /dev/null +++ b/pkg/edge/agent/bridge/files_other.go @@ -0,0 +1,7 @@ +//go:build !linux && !darwin + +package bridge + +import "os" + +func openAgentFile(root *os.Root, path string) (*os.File, error) { return nil, os.ErrPermission } diff --git a/pkg/edge/agent/bridge/files_test.go b/pkg/edge/agent/bridge/files_test.go new file mode 100644 index 00000000..90eea1f4 --- /dev/null +++ b/pkg/edge/agent/bridge/files_test.go @@ -0,0 +1,148 @@ +package bridge + +import ( + "bytes" + "context" + "image" + "image/png" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/liaisonio/liaison/pkg/proto" + "github.com/stretchr/testify/require" +) + +func fileFixture(t *testing.T) (*Bridge, proto.EdgeAgentRPCRequest) { + t.Helper() + b, _, start := setup(t) + start.AccessID = strings.Repeat("a", 32) + input := proto.EdgeAgentRPCRequest{Version: 1, ActorID: "owner", ProjectRoot: start.Project, Request: start} + out := b.Handle(context.Background(), input) + require.Equal(t, "ok", out.Status) + input.Request = proto.EdgeAgentRequest{AccessID: start.AccessID, SessionID: out.SessionID} + return b, input +} +func fileCall(b *Bridge, input proto.EdgeAgentRPCRequest, action string, f proto.AgentFileOperation) proto.EdgeAgentResult { + input.Request.Action = action + input.Request.File = &f + return b.Handle(context.Background(), input) +} +func uploadTestFile(t *testing.T, b *Bridge, input proto.EdgeAgentRPCRequest, name string, data []byte) proto.AgentFile { + t.Helper() + begun := fileCall(b, input, "file_begin", proto.AgentFileOperation{Name: name, Size: int64(len(data))}) + require.Equal(t, "ok", begun.Status) + for offset := 0; offset < len(data); offset += proto.AgentFileChunk { + out := fileCall(b, input, "file_write", proto.AgentFileOperation{TransferID: begun.TransferID, Offset: int64(offset), Data: data[offset:min(offset+proto.AgentFileChunk, len(data))]}) + require.Equal(t, "ok", out.Status) + } + committed := fileCall(b, input, "file_commit", proto.AgentFileOperation{TransferID: begun.TransferID}) + require.Equal(t, "ok", committed.Status) + require.NotNil(t, committed.File) + return *committed.File +} +func TestFileRoundTripAndDistinctNames(t *testing.T) { + b, input := fileFixture(t) + payload := bytes.Repeat([]byte("abc"), 100000) + first := uploadTestFile(t, b, input, "report.txt", payload) + second := uploadTestFile(t, b, input, "report.txt", []byte("new")) + require.NotEqual(t, first.Path, second.Path) + out := fileCall(b, input, "file_read", proto.AgentFileOperation{Path: first.Path}) + var got []byte + for { + require.Equal(t, "ok", out.Status) + got = append(got, out.FileData...) + if out.FileDone { + break + } + out = fileCall(b, input, "file_read", proto.AgentFileOperation{TransferID: out.TransferID, Offset: out.FileOffset}) + } + require.Equal(t, payload, got) + listing := fileCall(b, input, "file_list", proto.AgentFileOperation{Path: filepath.ToSlash(filepath.Dir(first.Path))}) + require.Equal(t, "ok", listing.Status) + require.Len(t, listing.Files, 2) + empty := uploadTestFile(t, b, input, "empty", nil) + out = fileCall(b, input, "file_read", proto.AgentFileOperation{Path: empty.Path}) + require.True(t, out.FileDone) + require.Empty(t, out.FileData) +} +func TestFilesRejectTraversalAndForeignScopes(t *testing.T) { + b, input := fileFixture(t) + outside := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(outside, "secret"), []byte("private"), 0600)) + require.NoError(t, os.Symlink(outside, filepath.Join(input.ProjectRoot, "escape"))) + for _, p := range []string{"../secret", "/etc/passwd", "escape/secret", "..\\secret"} { + t.Run(p, func(t *testing.T) { + out := fileCall(b, input, "file_read", proto.AgentFileOperation{Path: p}) + require.NotEqual(t, "ok", out.Status) + require.Empty(t, out.FileData) + }) + } + f := uploadTestFile(t, b, input, "hello", []byte("owned")) + foreign := input + foreign.ActorID = "other" + require.Equal(t, "not_found", fileCall(b, foreign, "file_read", proto.AgentFileOperation{Path: f.Path}).Status) + foreign = input + foreign.Request.AccessID = strings.Repeat("f", 32) + require.Equal(t, "not_found", fileCall(b, foreign, "file_read", proto.AgentFileOperation{Path: f.Path}).Status) + foreign = input + foreign.Request.SessionID = strings.Repeat("e", 32) + require.Equal(t, "not_found", fileCall(b, foreign, "file_read", proto.AgentFileOperation{Path: f.Path}).Status) +} +func TestIncompleteUploadsAreNotCommittedAndExpire(t *testing.T) { + b, input := fileFixture(t) + begin := func() string { + r := fileCall(b, input, "file_begin", proto.AgentFileOperation{Name: "partial", Size: 10}) + require.Equal(t, "ok", r.Status) + return r.TransferID + } + id := begin() + partial := filepath.ToSlash(filepath.Join(".liaison-attachments", input.Request.SessionID, ".upload-"+id)) + require.Equal(t, "invalid_request", fileCall(b, input, "file_read", proto.AgentFileOperation{Path: partial}).Status) + require.Equal(t, "invalid_request", fileCall(b, input, "file_commit", proto.AgentFileOperation{TransferID: id}).Status) + id = begin() + require.Equal(t, "ok", fileCall(b, input, "file_cancel", proto.AgentFileOperation{TransferID: id}).Status) + id = begin() + require.Equal(t, "invalid_request", fileCall(b, input, "file_write", proto.AgentFileOperation{TransferID: id, Offset: 2, Data: []byte("x")}).Status) + id = begin() + b.expireFiles(time.Now().Add(3*time.Minute), false) + require.Equal(t, "not_found", fileCall(b, input, "file_commit", proto.AgentFileOperation{TransferID: id}).Status) + entries, err := os.ReadDir(filepath.Join(input.ProjectRoot, ".liaison-attachments", input.Request.SessionID)) + require.NoError(t, err) + require.Empty(t, entries) +} +func TestImageInputsArePrivateSnapshots(t *testing.T) { + b, input := fileFixture(t) + var encoded bytes.Buffer + require.NoError(t, png.Encode(&encoded, image.NewRGBA(image.Rect(0, 0, 2, 2)))) + f := uploadTestFile(t, b, input, "image.png", encoded.Bytes()) + s := b.sessions[input.Request.SessionID] + display, inputs, temp, err := b.prepareAttachments(context.Background(), s, []string{f.Path}) + require.NoError(t, err) + defer removeImageInputs(temp) + require.Equal(t, "image/png", display[0].MediaType) + require.NotEqual(t, inputs[0].Path, inputs[0].ImagePath) + require.NoError(t, os.WriteFile(inputs[0].Path, []byte("changed"), 0600)) + actual, err := os.ReadFile(inputs[0].ImagePath) + require.NoError(t, err) + require.Equal(t, encoded.Bytes(), actual) + _, _, _, err = b.prepareAttachments(context.Background(), s, []string{f.Path}) + require.Error(t, err) + _, _, _, err = b.prepareAttachments(context.Background(), s, []string{"README.md"}) + require.Error(t, err) +} + +func TestImageUploadRejectsInvalidContentBeforePublishing(t *testing.T) { + b, input := fileFixture(t) + require.Equal(t, "invalid_request", fileCall(b, input, "file_begin", proto.AgentFileOperation{Name: "large.png", Size: (4 << 20) + 1}).Status) + data := []byte("not an image") + begin := fileCall(b, input, "file_begin", proto.AgentFileOperation{Name: "invalid.png", Size: int64(len(data))}) + require.Equal(t, "ok", begin.Status) + require.Equal(t, "ok", fileCall(b, input, "file_write", proto.AgentFileOperation{TransferID: begin.TransferID, Data: data}).Status) + require.Equal(t, "invalid_request", fileCall(b, input, "file_commit", proto.AgentFileOperation{TransferID: begin.TransferID}).Status) + entries, err := os.ReadDir(filepath.Join(input.ProjectRoot, ".liaison-attachments", input.Request.SessionID)) + require.NoError(t, err) + require.Empty(t, entries) +} diff --git a/pkg/edge/agent/bridge/files_unix.go b/pkg/edge/agent/bridge/files_unix.go new file mode 100644 index 00000000..27653aea --- /dev/null +++ b/pkg/edge/agent/bridge/files_unix.go @@ -0,0 +1,14 @@ +//go:build linux || darwin + +package bridge + +import ( + "os" + "syscall" +) + +// A replaced FIFO must not block a tunnel worker. Regular files and directories +// ignore O_NONBLOCK. os.Root independently prevents symlink traversal escapes. +func openAgentFile(root *os.Root, path string) (*os.File, error) { + return root.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) +} diff --git a/pkg/edge/agent/bridge/permissions.go b/pkg/edge/agent/bridge/permissions.go index 3714c7de..13cebed2 100644 --- a/pkg/edge/agent/bridge/permissions.go +++ b/pkg/edge/agent/bridge/permissions.go @@ -5,6 +5,9 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "errors" + "os" + "path/filepath" "strings" "github.com/liaisonio/liaison/pkg/edge/agent/rpc" @@ -18,25 +21,34 @@ type pendingApproval struct { turn string } -// Only complete, inspectable command requests can be approved remotely. +// Only complete, inspectable command/file requests can be approved remotely. // File-root/session permission grants and other request types stay fail-closed. func (s *session) queueApproval(event rpc.Message) bool { - if event.Method != "item/commandExecution/requestApproval" || len(event.Params) > 32768 { + isFile := event.Method == "item/fileChange/requestApproval" + if (event.Method != "item/commandExecution/requestApproval" && !isFile) || len(event.ID) == 0 || len(event.Params) > 32768 { return false } if _, ok := s.agent.(agentruntime.PermissionSession); !ok { return false } + if isFile { + if _, ok := s.agent.(agentruntime.FileApprovalSession); !ok { + return false + } + } var p struct { - Thread string `json:"threadId"` - Turn string `json:"turnId"` - Command string `json:"command"` - Cwd string `json:"cwd"` - Reason string `json:"reason"` - Kind string `json:"kind"` - Network json.RawMessage `json:"networkApprovalContext"` - } - if json.Unmarshal(event.Params, &p) != nil || strings.TrimSpace(p.Command) == "" || len(p.Command) > 16384 || (p.Kind != "" && p.Kind != "command") || (len(p.Network) > 0 && string(p.Network) != "null") { + Item string `json:"itemId"` + GrantRoot json.RawMessage `json:"grantRoot"` + Additional json.RawMessage `json:"additionalPermissions"` + Thread string `json:"threadId"` + Turn string `json:"turnId"` + Command string `json:"command"` + Cwd string `json:"cwd"` + Reason string `json:"reason"` + Kind string `json:"kind"` + Network json.RawMessage `json:"networkApprovalContext"` + } + if json.Unmarshal(event.Params, &p) != nil || (!isFile && (strings.TrimSpace(p.Command) == "" || len(p.Command) > 16384 || (p.Kind != "" && p.Kind != "command"))) || (len(p.Network) > 0 && string(p.Network) != "null") || (len(p.GrantRoot) > 0 && string(p.GrantRoot) != "null") || (len(p.Additional) > 0 && string(p.Additional) != "null") { return false } s.mu.Lock() @@ -49,11 +61,40 @@ func (s *session) queueApproval(event rpc.Message) bool { return true } } + view := proto.AgentApproval{Kind: "commandExecution", Command: p.Command, Directory: p.Cwd, Reason: p.Reason} + if isFile { + i := s.activityIndexLocked(p.Item) + if p.Item == "" || i < 0 { + return false + } + a := s.activities[i] + if a.Kind != "fileChange" || a.Status != "running" || a.Truncated || len(a.Changes) == 0 { + return false + } + for _, change := range a.Changes { + if change.Diff == "" || !approvalPathInProject(s.project, change.Path) || (change.MovePath != "" && !approvalPathInProject(s.project, change.MovePath)) { + return false + } + } + view.Kind = "fileChange" + view.Command = "" + view.Directory = s.project + view.Changes = append([]proto.AgentFileChange{}, a.Changes...) + } var id [16]byte if _, err := rand.Read(id[:]); err != nil { return false } - s.approvals = append(s.approvals, pendingApproval{view: proto.AgentApproval{ID: hex.EncodeToString(id[:]), Command: p.Command, Directory: p.Cwd, Reason: p.Reason}, request: event, turn: p.Turn}) + view.ID = hex.EncodeToString(id[:]) + views := []proto.AgentApproval{view} + for _, pending := range s.approvals { + views = append(views, pending.view) + } + encoded, err := json.Marshal(views) + if err != nil || len(encoded) > 128<<10 { + return false + } + s.approvals = append(s.approvals, pendingApproval{view: view, request: event, turn: p.Turn}) s.changedLocked() return true } @@ -109,14 +150,23 @@ func (s *session) managePermissions(ctx context.Context, req proto.EdgeAgentRequ } // Consume before writing; an uncertain write must never be replayed. s.approvals = append(s.approvals[:i], s.approvals[i+1:]...) + s.changedLocked() + s.mu.Unlock() var err error if req.Decision == "accept" { - err = capable.ApproveCommand(ctx, pending.request) + if pending.view.Kind == "fileChange" { + fileCapable, ok := s.agent.(agentruntime.FileApprovalSession) + if !ok { + err = agentruntime.ErrUnavailable + } else { + err = fileCapable.ApproveFileChange(ctx, pending.request) + } + } else { + err = capable.ApproveCommand(ctx, pending.request) + } } else { err = s.agent.RejectRequest(ctx, pending.request) } - s.changedLocked() - s.mu.Unlock() if err != nil { s.stop("unavailable") } @@ -125,3 +175,31 @@ func (s *session) managePermissions(ctx context.Context, req proto.EdgeAgentRequ s.mu.Unlock() return result("invalid_request") } + +// Lexical containment plus an existing canonical parent. New files are allowed; +// symlinks and missing intermediate directories are not remotely approved. +func approvalPathInProject(project, path string) bool { + if project == "" || path == "" { + return false + } + if !filepath.IsAbs(path) { + path = filepath.Join(project, path) + } + rel, err := filepath.Rel(project, path) + if err != nil || !filepath.IsLocal(rel) || rel == "." { + return false + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + if _, e := os.Lstat(path); !errors.Is(e, os.ErrNotExist) { + return false + } + parent, e := filepath.EvalSymlinks(filepath.Dir(path)) + if e != nil { + return false + } + resolved = filepath.Join(parent, filepath.Base(path)) + } + rel, err = filepath.Rel(project, resolved) + return err == nil && filepath.IsLocal(rel) && rel != "." +} diff --git a/pkg/edge/agent/bridge/plan_test.go b/pkg/edge/agent/bridge/plan_test.go new file mode 100644 index 00000000..3ea92125 --- /dev/null +++ b/pkg/edge/agent/bridge/plan_test.go @@ -0,0 +1,59 @@ +package bridge + +import ( + "encoding/json" + "github.com/liaisonio/liaison/pkg/edge/agent/rpc" + "github.com/liaisonio/liaison/pkg/proto" + "github.com/stretchr/testify/require" + "strings" + "testing" + "time" +) + +func TestPlanAndToolMetadataScopedToTurn(t *testing.T) { + b, a, req := setup(t) + req.AccessID = strings.Repeat("a", 32) + started := call(b, "alice", req) + poll := proto.EdgeAgentRequest{Action: "poll", AccessID: req.AccessID, SessionID: started.SessionID} + call(b, "alice", proto.EdgeAgentRequest{Action: "send", AccessID: req.AccessID, SessionID: started.SessionID, Text: "Inspect"}) + emit := func(method, params string) { a.events <- rpc.Message{Method: method, Params: json.RawMessage(params)} } + emit("turn/plan/updated", `{"threadId":"foreign","turnId":"turn-owned","plan":[{"step":"wrong owner","status":"pending"}]}`) + emit("turn/plan/updated", `{"threadId":"thread-owned","turnId":"foreign","plan":[{"step":"wrong turn","status":"pending"}]}`) + emit("turn/plan/updated", `{"threadId":"thread-owned","turnId":"turn-owned","explanation":"Checking the project","plan":[{"step":"Inspect","status":"completed"},{"step":"Verify","status":"inProgress"}]}`) + emit("item/completed", `{"threadId":"thread-owned","turnId":"turn-owned","item":{"id":"search","type":"webSearch","query":"SQLite WAL"}}`) + emit("item/completed", `{"threadId":"thread-owned","turnId":"turn-owned","item":{"id":"tool","type":"mcpToolCall","server":"docs","tool":"search","arguments":{"secret":"do-not-store"},"result":"do-not-store"}}`) + emit("item/completed", `{"threadId":"thread-owned","turnId":"turn-owned","item":{"id":"proposed","type":"plan","text":"## Plan\n\nRun tests"}}`) + require.Eventually(t, func() bool { return len(call(b, "alice", poll).Activities) == 4 }, time.Second, time.Millisecond) + out := call(b, "alice", poll) + require.Len(t, out.Activities[0].Plan, 2) + require.Equal(t, "inProgress", out.Activities[0].Plan[1].Status) + require.Equal(t, "SQLite WAL", out.Activities[1].Label) + require.Equal(t, "docs / search", out.Activities[2].Label) + require.Contains(t, out.Activities[3].Output, "Run tests") + raw, err := json.Marshal(out) + require.NoError(t, err) + require.NotContains(t, string(raw), "do-not-store") + require.NotContains(t, string(raw), "wrong") + emit("turn/plan/updated", `{"threadId":"thread-owned","turnId":"turn-owned","plan":[{"step":"Inspect","status":"completed"},{"step":"Verify","status":"completed"}]}`) + require.Eventually(t, func() bool { return call(b, "alice", poll).Activities[0].Status == "completed" }, time.Second, time.Millisecond) +} + +func TestPlanBudgetsAndURLMetadata(t *testing.T) { + s := &session{access: strings.Repeat("a", 32)} + steps := make([]proto.AgentPlanStep, 100) + for i := range steps { + steps[i] = proto.AgentPlanStep{Step: strings.Repeat("长路径", 1000), Status: "pending"} + } + s.recordPlanLocked("turn", "", steps) + require.True(t, s.activities[0].Truncated) + require.LessOrEqual(t, len(s.activities[0].Plan), 32) + require.LessOrEqual(t, activitySize(s.activities[0]), maxActivityText) + s.recordActivityLocked("url", "webSearch", "", 0, true) + item := nativeActivity{ID: "url", Type: "webSearch"} + item.Action.URL = "https://user:password@example.com/docs?key=secret#fragment" + s.recordActivityDetailsLocked(item) + require.Equal(t, "https://example.com/docs", s.activities[1].Label) + preview := &session{} + preview.recordPlanLocked("turn", "private", steps) + require.Empty(t, preview.activities) +} diff --git a/pkg/edge/agent/runtime/runtime.go b/pkg/edge/agent/runtime/runtime.go index 7548f9e1..5c8f6b3e 100644 --- a/pkg/edge/agent/runtime/runtime.go +++ b/pkg/edge/agent/runtime/runtime.go @@ -23,6 +23,12 @@ type Thread struct { } type Model struct{ ID, Name string } +// Attachment paths are prepared by the trusted Edge file service, not the browser. +type Attachment struct{ Path, ImagePath string } +type AttachmentSession interface { + SendAttachments(context.Context, string, string, string, string, []Attachment) (string, error) +} + // Optional native catalog and per-turn model selection. Never writes local configuration. type ModelSession interface { Models(context.Context) ([]Model, error) @@ -59,6 +65,11 @@ type PermissionSession interface { ApproveCommand(context.Context, rpc.Message) error } +// FileApprovalSession accepts only a reviewed single request, never a root grant. +type FileApprovalSession interface { + ApproveFileChange(context.Context, rpc.Message) error +} + type InputSession interface { AnswerInput(context.Context, rpc.Message, map[string][]string) error } diff --git a/pkg/liaison/manager/controlplane/agent_access.go b/pkg/liaison/manager/controlplane/agent_access.go index 57725ffe..4d576f85 100644 --- a/pkg/liaison/manager/controlplane/agent_access.go +++ b/pkg/liaison/manager/controlplane/agent_access.go @@ -9,6 +9,7 @@ import ( ) type AgentAccessInput struct { + ApplicationID string `json:"application_id"` Name string `json:"name"` Kind string `json:"kind"` EdgeID uint64 `json:"edge_id"` @@ -51,6 +52,19 @@ func (cp *controlPlane) SaveAgentAccess(ctx context.Context, id string, input Ag } input.Name = strings.TrimSpace(input.Name) input.Project = strings.TrimSpace(input.Project) + if input.ApplicationID != "" { + if len(input.ApplicationID) != 32 { + return nil, badRequest("INVALID_APPLICATION", "Invalid application") + } + app, err := cp.repo.GetAgentApplication(ctx, actor, input.ApplicationID) + if err != nil { + return nil, mapRecordNotFound(err, "APPLICATION_NOT_FOUND", "Application unavailable") + } + if (input.EdgeID != 0 && input.EdgeID != app.EdgeID) || (input.Kind != "" && input.Kind != app.Kind) || (input.InstallationID != "" && input.InstallationID != app.InstallationID) { + return nil, badRequest("APPLICATION_MISMATCH", "Application does not match access") + } + input.EdgeID, input.Kind, input.InstallationID = app.EdgeID, app.Kind, app.InstallationID + } if input.Kind != "codex" || input.Name == "" || len(input.Name) > 120 || len(input.InstallationID) != 32 || input.Project == "" || len(input.Project) > 4096 || strings.ContainsRune(input.Project, 0) || (id != "" && len(id) != 32) { return nil, badRequest("INVALID_AGENT_ACCESS", "Invalid Agent access") } @@ -72,7 +86,7 @@ func (cp *controlPlane) SaveAgentAccess(ctx context.Context, id string, input Ag return nil, mapRecordNotFound(err, "ACCESS_NOT_FOUND", "Access unavailable") } } - row := &model.AgentAccess{ID: id, OwnerID: actor, Name: input.Name, Kind: input.Kind, EdgeID: input.EdgeID, InstallationID: input.InstallationID, Project: input.Project} + row := &model.AgentAccess{ApplicationID: input.ApplicationID, ID: id, OwnerID: actor, Name: input.Name, Kind: input.Kind, EdgeID: input.EdgeID, InstallationID: input.InstallationID, Project: input.Project} if err := cp.repo.SaveAgentAccess(ctx, row, create); err != nil { return nil, mapRecordNotFound(err, "ACCESS_NOT_FOUND", "Access unavailable") } diff --git a/pkg/liaison/manager/controlplane/agent_application.go b/pkg/liaison/manager/controlplane/agent_application.go new file mode 100644 index 00000000..ced612b5 --- /dev/null +++ b/pkg/liaison/manager/controlplane/agent_application.go @@ -0,0 +1,121 @@ +package controlplane + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "github.com/liaisonio/liaison/pkg/liaison/repo/dao" + "github.com/liaisonio/liaison/pkg/liaison/repo/model" + "github.com/liaisonio/liaison/pkg/proto" + "strings" +) + +type AgentApplicationInput struct { + Name string `json:"name"` + Kind string `json:"kind"` + EdgeID uint64 `json:"edge_id"` + InstallationID string `json:"installation_id"` +} +type AgentApplicationList struct { + Items []model.AgentApplication `json:"items"` + Total int64 `json:"total"` +} + +func (cp *controlPlane) GetAgentApplication(ctx context.Context, id string) (*model.AgentApplication, error) { + actor, err := cp.agentActor(ctx) + if err != nil { + return nil, err + } + row, err := cp.repo.GetAgentApplication(ctx, actor, id) + if err != nil { + return nil, mapRecordNotFound(err, "APPLICATION_NOT_FOUND", "Application unavailable") + } + if !cp.ownsAgentConnector(actor, row.EdgeID) { + return nil, notFound("CONNECTOR_NOT_FOUND", "Connector unavailable", nil) + } + return row, nil +} +func (cp *controlPlane) AgentApplications(ctx context.Context, page, size int, edge uint64) (AgentApplicationList, error) { + actor, err := cp.agentActor(ctx) + if err != nil { + return AgentApplicationList{}, err + } + if page < 1 || page > 10000 || size < 1 || size > 100 { + return AgentApplicationList{}, badRequest("INVALID_PAGE", "Invalid page") + } + if edge != 0 && !cp.ownsAgentConnector(actor, edge) { + return AgentApplicationList{}, notFound("CONNECTOR_NOT_FOUND", "Connector unavailable", nil) + } + rows, total, err := cp.repo.ListAgentApplications(ctx, actor, edge, page, size) + return AgentApplicationList{rows, total}, err +} +func (cp *controlPlane) SaveAgentApplication(ctx context.Context, id string, input AgentApplicationInput) (*model.AgentApplication, error) { + actor, err := cp.agentActor(ctx) + if err != nil { + return nil, err + } + input.Name = strings.TrimSpace(input.Name) + if input.Name == "" || len(input.Name) > 120 || (id != "" && len(id) != 32) { + return nil, badRequest("INVALID_APPLICATION", "Invalid application") + } + create := id == "" + var row *model.AgentApplication + if create { + if input.Kind != "codex" || len(input.InstallationID) != 32 { + return nil, badRequest("INVALID_APPLICATION", "Invalid application") + } + result, err := cp.edgeAgentLive(ctx, proto.EdgeAgentRequest{Action: "discover", EdgeID: input.EdgeID}) + if err != nil { + return nil, err + } + found := false + for _, i := range result.Installations { + if i.ID == input.InstallationID && i.Kind == input.Kind { + found = true + } + } + if result.Status != "ok" || !found { + return nil, badRequest("INSTALLATION_UNAVAILABLE", "Discover an available installation first") + } + var random [16]byte + if _, err := rand.Read(random[:]); err != nil { + return nil, err + } + row = &model.AgentApplication{ID: hex.EncodeToString(random[:]), OwnerID: actor, EdgeID: input.EdgeID, Kind: input.Kind, InstallationID: input.InstallationID} + } else { + row, err = cp.repo.GetAgentApplication(ctx, actor, id) + if err != nil { + return nil, mapRecordNotFound(err, "APPLICATION_NOT_FOUND", "Application unavailable") + } + if !cp.ownsAgentConnector(actor, row.EdgeID) { + return nil, notFound("CONNECTOR_NOT_FOUND", "Connector unavailable", nil) + } + if (input.EdgeID != 0 && input.EdgeID != row.EdgeID) || (input.Kind != "" && input.Kind != row.Kind) || (input.InstallationID != "" && input.InstallationID != row.InstallationID) { + return nil, badRequest("IMMUTABLE_INSTALLATION", "Installation association cannot be changed") + } + } + row.Name = input.Name + if err := cp.repo.SaveAgentApplication(ctx, row, create); err != nil { + return nil, mapRecordNotFound(err, "APPLICATION_NOT_FOUND", "Application unavailable") + } + return row, nil +} +func (cp *controlPlane) DeleteAgentApplication(ctx context.Context, id string) error { + actor, err := cp.agentActor(ctx) + if err != nil { + return err + } + row, err := cp.repo.GetAgentApplication(ctx, actor, id) + if err != nil { + return mapRecordNotFound(err, "APPLICATION_NOT_FOUND", "Application unavailable") + } + if !cp.ownsAgentConnector(actor, row.EdgeID) { + return notFound("CONNECTOR_NOT_FOUND", "Connector unavailable", nil) + } + err = cp.repo.DeleteAgentApplication(ctx, actor, id) + if errors.Is(err, dao.ErrAgentApplicationInUse) { + return conflict("APPLICATION_IN_USE", "Remove linked accesses first") + } + return mapRecordNotFound(err, "APPLICATION_NOT_FOUND", "Application unavailable") +} diff --git a/pkg/liaison/manager/controlplane/agent_application_test.go b/pkg/liaison/manager/controlplane/agent_application_test.go new file mode 100644 index 00000000..59280f23 --- /dev/null +++ b/pkg/liaison/manager/controlplane/agent_application_test.go @@ -0,0 +1,78 @@ +package controlplane + +import ( + "context" + "github.com/liaisonio/liaison/pkg/liaison/repo/dao" + "github.com/liaisonio/liaison/pkg/liaison/repo/model" + "github.com/liaisonio/liaison/pkg/proto" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "strings" + "testing" +) + +type applicationAgentRepo struct { + *entryAgentRepo + app model.AgentApplication + saved int +} + +func (r *applicationAgentRepo) GetAgentApplication(_ context.Context, owner uint, id string) (*model.AgentApplication, error) { + if owner != r.app.OwnerID || id != r.app.ID { + return nil, gorm.ErrRecordNotFound + } + copy := r.app + return ©, nil +} +func (r *applicationAgentRepo) SaveAgentApplication(_ context.Context, row *model.AgentApplication, _ bool) error { + r.saved++ + r.app = *row + return nil +} +func (r *applicationAgentRepo) SaveAgentAccess(_ context.Context, row *model.AgentAccess, _ bool) error { + r.saved++ + r.entry = row + return nil +} +func (r *applicationAgentRepo) DeleteAgentApplication(context.Context, uint, string) error { + return dao.ErrAgentApplicationInUse +} + +type installationAgentFrontier struct{ *agentFrontier } + +func (f *installationAgentFrontier) EdgeAgent(ctx context.Context, id uint64, req proto.EdgeAgentRPCRequest) (proto.EdgeAgentResult, error) { + result, err := f.agentFrontier.EdgeAgent(ctx, id, req) + result.Installations = []proto.AgentInstallation{{ID: strings.Repeat("b", 32), Kind: "codex"}} + return result, err +} + +func TestAgentApplicationOwnershipAndAccessBinding(t *testing.T) { + r := &applicationAgentRepo{entryAgentRepo: &entryAgentRepo{agentRepo: &agentRepo{installationRepo: &installationRepo{edge: model.Edge{Model: gorm.Model{ID: 7}, Online: model.EdgeOnlineStatusOnline, Status: model.EdgeStatusRunning}}, owner: 2}}, app: model.AgentApplication{ID: strings.Repeat("a", 32), OwnerID: 2, EdgeID: 7, Kind: "codex", InstallationID: strings.Repeat("b", 32), Name: "Codex"}} + f := &installationAgentFrontier{&agentFrontier{}} + cp := &controlPlane{repo: r, frontierBound: f, authorizeFeature: func(context.Context, string) error { return nil }} + ctx := context.WithValue(context.Background(), "user_id", uint(2)) + input := AgentAccessInput{ApplicationID: r.app.ID, Name: "Access", Project: "/project"} + row, err := cp.SaveAgentAccess(ctx, "", input) + require.NoError(t, err) + require.Equal(t, r.app.InstallationID, row.InstallationID) + require.Equal(t, r.app.ID, row.ApplicationID) + require.Zero(t, f.calls) + input.EdgeID = 8 + _, err = cp.SaveAgentAccess(ctx, "", input) + require.Error(t, err) + require.Equal(t, 1, r.saved) + input.EdgeID = 0 + _, err = cp.SaveAgentAccess(context.WithValue(ctx, "user_id", uint(3)), "", input) + require.Error(t, err) + _, err = cp.SaveAgentApplication(ctx, r.app.ID, AgentApplicationInput{Name: "Rename", InstallationID: "different"}) + require.Error(t, err) + _, err = cp.SaveAgentApplication(ctx, r.app.ID, AgentApplicationInput{Name: "Rename"}) + require.NoError(t, err) + require.Zero(t, f.calls) + require.Error(t, cp.DeleteAgentApplication(ctx, r.app.ID)) + _, err = cp.SaveAgentApplication(ctx, "", AgentApplicationInput{Name: "New", Kind: "codex", EdgeID: 7, InstallationID: strings.Repeat("c", 32)}) + require.Error(t, err) + _, err = cp.SaveAgentApplication(ctx, "", AgentApplicationInput{Name: "New", Kind: "codex", EdgeID: 7, InstallationID: strings.Repeat("b", 32)}) + require.NoError(t, err) + require.Equal(t, 2, f.calls) +} diff --git a/pkg/liaison/manager/controlplane/edge_agent.go b/pkg/liaison/manager/controlplane/edge_agent.go index 1a1a071c..1bbc54c9 100644 --- a/pkg/liaison/manager/controlplane/edge_agent.go +++ b/pkg/liaison/manager/controlplane/edge_agent.go @@ -3,6 +3,7 @@ package controlplane import ( "context" "strconv" + "strings" "time" "github.com/liaisonio/liaison/pkg/liaison/manager/iam" @@ -95,6 +96,16 @@ func (cp *controlPlane) edgeAgentLiveEnvelope(ctx context.Context, req proto.Edg if req.EdgeID == 0 || !req.Valid() { return proto.EdgeAgentResult{}, badRequest("INVALID_AGENT_REQUEST", "Invalid agent request") } + if strings.HasPrefix(req.Action, "file_") || len(req.Attachments) > 0 { + if err := cp.authorizeFeature(ctx, iam.FeatureFilesRead); err != nil { + return proto.EdgeAgentResult{}, err + } + if req.Action == "file_begin" || req.Action == "file_write" || req.Action == "file_commit" || len(req.Attachments) > 0 { + if err := cp.authorizeFeature(ctx, iam.FeatureFilesUpload); err != nil { + return proto.EdgeAgentResult{}, err + } + } + } projectRoot := "" if req.AccessID != "" { entry, err := cp.repo.GetAgentAccess(ctx, actor, req.AccessID) @@ -136,9 +147,18 @@ func (cp *controlPlane) edgeAgentLiveEnvelope(ctx context.Context, req proto.Edg fallback.Status = "upgrade_required" return fallback, nil } + if reply.FilesAvailable { + reply.FilesAvailable = cp.authorizeFeature(ctx, iam.FeatureFilesRead) == nil + reply.FilesUploadAvailable = reply.FilesAvailable && cp.authorizeFeature(ctx, iam.FeatureFilesUpload) == nil + } // A watch may wait for 15 seconds. Recheck ownership after the wait before // returning newly produced data, including entry deletion/reassignment. - if req.Action == "watch" { + if req.Action == "watch" || strings.HasPrefix(req.Action, "file_") { + if strings.HasPrefix(req.Action, "file_") { + if err := cp.authorizeFeature(ctx, iam.FeatureFilesRead); err != nil { + return proto.EdgeAgentResult{}, err + } + } if _, err := cp.agentActor(ctx); err != nil { return proto.EdgeAgentResult{}, err } @@ -157,7 +177,7 @@ func (cp *controlPlane) edgeAgentLiveEnvelope(ctx context.Context, req proto.Edg default: return fallback, nil } - if req.Action == "answer" || req.Action == "approve" || req.Action == "permissions" || req.Action == "start" || req.Action == "resume" || req.Action == "send" || req.Action == "stop" || req.Action == "interrupt" || req.Action == "model" || req.Action == "rename" || req.Action == "delete" || req.Action == "discard" { + if req.Action == "file_begin" || req.Action == "file_commit" || (req.Action == "file_read" && req.File.TransferID == "") || req.Action == "answer" || req.Action == "approve" || req.Action == "permissions" || req.Action == "start" || req.Action == "resume" || req.Action == "send" || req.Action == "stop" || req.Action == "interrupt" || req.Action == "model" || req.Action == "rename" || req.Action == "delete" || req.Action == "discard" { // No prompt, output, path or credential is written to management logs. if err := cp.RecordManagementAudit(ctx, &ManagementAudit{UserID: actor, Module: "agent", Action: req.Action, Resource: "connector/" + strconv.FormatUint(req.EdgeID, 10), Method: "POST", Success: reply.Status == "ok" || reply.Status == "session_closed", StatusCode: 200}); err != nil { klog.Warning("agent management audit could not be recorded") diff --git a/pkg/liaison/manager/controlplane/edge_agent_files_test.go b/pkg/liaison/manager/controlplane/edge_agent_files_test.go new file mode 100644 index 00000000..54889abb --- /dev/null +++ b/pkg/liaison/manager/controlplane/edge_agent_files_test.go @@ -0,0 +1,50 @@ +package controlplane + +import ( + "context" + "strings" + "testing" + + "github.com/liaisonio/liaison/pkg/liaison/manager/iam" + "github.com/liaisonio/liaison/pkg/liaison/repo/model" + "github.com/liaisonio/liaison/pkg/proto" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestAgentFilesRequireAccessOwnershipAndFilePermissions(t *testing.T) { + id := strings.Repeat("a", 32) + repo := &entryAgentRepo{agentRepo: &agentRepo{installationRepo: &installationRepo{edge: model.Edge{Model: gorm.Model{ID: 7}, Online: model.EdgeOnlineStatusOnline, Status: model.EdgeStatusRunning}}, owner: 2}, entry: &model.AgentAccess{ID: id, OwnerID: 2, EdgeID: 7, Project: "/project"}} + frontier := &agentFrontier{} + deny := "" + cp := &controlPlane{repo: repo, frontierBound: frontier, authorizeFeature: func(_ context.Context, feature string) error { + if feature == deny { + return iam.ErrForbidden + } + return nil + }} + ctx := context.WithValue(context.Background(), "user_id", uint(2)) + req := proto.EdgeAgentRequest{EdgeID: 7, AccessID: id, SessionID: strings.Repeat("s", 32), Action: "file_begin", File: &proto.AgentFileOperation{Name: "test.txt", Size: 1}} + deny = iam.FeatureFilesUpload + _, err := cp.EdgeAgent(ctx, req) + require.ErrorIs(t, err, iam.ErrForbidden) + require.Zero(t, frontier.calls) + req.Action = "file_read" + req.File = &proto.AgentFileOperation{Path: "test.txt"} + deny = iam.FeatureFilesRead + _, err = cp.EdgeAgent(ctx, req) + require.ErrorIs(t, err, iam.ErrForbidden) + require.Zero(t, frontier.calls) + deny = "" + repo.entry.OwnerID = 3 + _, err = cp.EdgeAgent(ctx, req) + require.Error(t, err) + require.Zero(t, frontier.calls) + repo.entry.OwnerID = 2 + _, err = cp.EdgeAgent(ctx, req) + require.NoError(t, err) + require.Equal(t, 1, frontier.calls) + frontier.afterCall = func() { deny = iam.FeatureFilesRead } + _, err = cp.EdgeAgent(ctx, req) + require.ErrorIs(t, err, iam.ErrForbidden, "Revoke read permission while a file chunk is in flight") +} diff --git a/pkg/liaison/manager/controlplane/edge_agent_history.go b/pkg/liaison/manager/controlplane/edge_agent_history.go index 8ce9aa37..71dcbd6d 100644 --- a/pkg/liaison/manager/controlplane/edge_agent_history.go +++ b/pkg/liaison/manager/controlplane/edge_agent_history.go @@ -179,7 +179,7 @@ func (cp *controlPlane) EdgeAgent(ctx context.Context, req proto.EdgeAgentReques if saved == nil { return proto.EdgeAgentResult{Version: 1, Status: "not_found"}, nil } - return cp.previousTranscript(ctx, scope, req.HistoryBefore) + return cp.previousTranscript(ctx, scope, req.HistoryBefore, req.HistoryLimit) } if req.Action == "send" { currentReq := proto.EdgeAgentRequest{Action: "snapshot", EdgeID: req.EdgeID, AccessID: req.AccessID, SessionID: req.SessionID} diff --git a/pkg/liaison/manager/controlplane/edge_agent_history_test.go b/pkg/liaison/manager/controlplane/edge_agent_history_test.go index 49ddefb1..da23870e 100644 --- a/pkg/liaison/manager/controlplane/edge_agent_history_test.go +++ b/pkg/liaison/manager/controlplane/edge_agent_history_test.go @@ -87,6 +87,15 @@ func TestHistoryOfflineRestartEncryptionAndBackgroundSync(t *testing.T) { require.Equal(t, snapshot.Messages, pageResult.Messages) require.Empty(t, pageResult.InputRequests) require.Empty(t, pageResult.Approvals) + batch, err := cp.EdgeAgent(ctx, proto.EdgeAgentRequest{Action: "transcript", AccessID: access, EdgeID: 7, SessionID: id, HistoryBefore: "1", HistoryLimit: "20"}) + require.NoError(t, err) + require.Len(t, batch.HistoryPages, 1) + require.Equal(t, snapshot.Messages, batch.HistoryPages[0].Messages) + require.Empty(t, batch.HistoryBefore) + require.Empty(t, batch.HistoryPages[0].Approvals) + require.Empty(t, batch.HistoryPages[0].InputRequests) + _, err = cp.EdgeAgent(context.WithValue(ctx, "user_id", uint(2)), proto.EdgeAgentRequest{Action: "transcript", AccessID: access, EdgeID: 7, SessionID: id, HistoryBefore: "1", HistoryLimit: "20"}) + require.Error(t, err) _, err = cp.EdgeAgent(context.WithValue(ctx, "user_id", uint(2)), proto.EdgeAgentRequest{Action: "transcript", AccessID: access, EdgeID: 7, SessionID: id, HistoryBefore: "1"}) require.Error(t, err) // Recreate the control plane: only the repository and stable key survive. diff --git a/pkg/liaison/manager/controlplane/edge_agent_transcript.go b/pkg/liaison/manager/controlplane/edge_agent_transcript.go index 55604c6d..f08ff6df 100644 --- a/pkg/liaison/manager/controlplane/edge_agent_transcript.go +++ b/pkg/liaison/manager/controlplane/edge_agent_transcript.go @@ -26,25 +26,41 @@ func (cp *controlPlane) archiveTranscript(ctx context.Context, scope *model.Edge return cp.repo.SaveEdgeAgentHistoryPage(ctx, &model.EdgeAgentHistoryPage{OwnerID: scope.OwnerID, AccessID: scope.AccessID, EdgeID: scope.EdgeID, SessionID: scope.SessionID, Window: snapshot.Window, Revision: snapshot.Revision, Payload: encrypted}) } -func (cp *controlPlane) previousTranscript(ctx context.Context, scope *model.EdgeAgentHistory, cursor string) (proto.EdgeAgentResult, error) { +func (cp *controlPlane) previousTranscript(ctx context.Context, scope *model.EdgeAgentHistory, cursor, limitText string) (proto.EdgeAgentResult, error) { before, err := strconv.ParseUint(cursor, 10, 63) if err != nil { return proto.EdgeAgentResult{}, err } - page, err := cp.repo.PreviousEdgeAgentHistoryPage(ctx, scope, before) + limit := 1 + if limitText != "" { + limit, err = strconv.Atoi(limitText) + if err != nil || limit < 1 || limit > 20 { + return proto.EdgeAgentResult{}, errors.New("invalid history limit") + } + } + pages, err := cp.repo.ListEdgeAgentHistoryPages(ctx, scope, before, limit, (2<<20)-1024) if errors.Is(err, gorm.ErrRecordNotFound) { return proto.EdgeAgentResult{Version: 1, Status: "not_found"}, nil } if err != nil { return proto.EdgeAgentResult{}, err } - raw, err := cp.openHistory(page.Payload, historyAAD(scope)+"/page/"+strconv.FormatUint(page.Window, 10)) - if err != nil { - return proto.EdgeAgentResult{}, err - } - var result proto.EdgeAgentResult - if err = json.Unmarshal(raw, &result); err != nil { - return result, err + result := proto.EdgeAgentResult{Version: 1, Status: "ok", SessionID: scope.SessionID, HistoryPersistent: true} + for _, page := range pages { + raw, openErr := cp.openHistory(page.Payload, historyAAD(scope)+"/page/"+strconv.FormatUint(page.Window, 10)) + if openErr != nil { + return proto.EdgeAgentResult{}, openErr + } + var snapshot proto.EdgeAgentResult + if err = json.Unmarshal(raw, &snapshot); err != nil { + return proto.EdgeAgentResult{}, err + } + result.HistoryPages = append(result.HistoryPages, snapshot) + if page.Window > 0 { + result.HistoryBefore = strconv.FormatUint(page.Window, 10) + } else { + result.HistoryBefore = "" + } } // A deletion or ownership change during a delayed read must not expose history. if _, err = cp.historyScope(ctx, proto.EdgeAgentRequest{Action: "transcript", EdgeID: scope.EdgeID, AccessID: scope.AccessID, SessionID: scope.SessionID, HistoryBefore: cursor}); err != nil { @@ -57,5 +73,11 @@ func (cp *controlPlane) previousTranscript(ctx context.Context, scope *model.Edg if parent.Deleted { return proto.EdgeAgentResult{Version: 1, Status: "not_found"}, nil } + if limitText == "" { + if len(result.HistoryPages) == 0 { + return proto.EdgeAgentResult{Version: 1, Status: "not_found"}, nil + } + return result.HistoryPages[0], nil + } return result, nil } diff --git a/pkg/liaison/manager/web/agent_application_http.go b/pkg/liaison/manager/web/agent_application_http.go new file mode 100644 index 00000000..12a3bf1e --- /dev/null +++ b/pkg/liaison/manager/web/agent_application_http.go @@ -0,0 +1,94 @@ +package web + +import ( + "context" + "encoding/json" + "errors" + kerrors "github.com/go-kratos/kratos/v2/errors" + "github.com/liaisonio/liaison/pkg/liaison/manager/controlplane" + "github.com/liaisonio/liaison/pkg/liaison/manager/iam" + "github.com/liaisonio/liaison/pkg/liaison/repo/model" + "io" + "net/http" + "strconv" + "strings" +) + +type agentApplicationService interface { + GetAgentApplication(context.Context, string) (*model.AgentApplication, error) + AgentApplications(context.Context, int, int, uint64) (controlplane.AgentApplicationList, error) + SaveAgentApplication(context.Context, string, controlplane.AgentApplicationInput) (*model.AgentApplication, error) + DeleteAgentApplication(context.Context, string) error +} + +// @Summary Manage reusable Agent application entries owned by the current user +// @Router /api/v1/agent-applications [get] +// @Router /api/v1/agent-applications [post] +// @Router /api/v1/agent-applications/{id} [put] +// @Router /api/v1/agent-applications/{id} [delete] +// @Success 200 {object} controlplane.AgentApplicationList +func (web *web) handleAgentApplicationHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + actor, err := web.authenticateHTTP(r) + if err != nil { + writeUnauthorized(w) + return + } + if web.iamService == nil || web.iamService.RequireResourcePermission(actor, "connectors", "update") != nil { + writeJSON(w, 403, map[string]any{"code": 403, "message": "forbidden"}) + return + } + svc, ok := web.controlPlane.(agentApplicationService) + if !ok { + writeJSON(w, 503, map[string]any{"code": 503, "message": "unavailable"}) + return + } + ctx := context.WithValue(r.Context(), "user", actor) + ctx = context.WithValue(ctx, "user_id", actor.ID) + id := strings.TrimPrefix(r.URL.Path, "/api/v1/agent-applications") + id = strings.TrimPrefix(id, "/") + var data any + switch { + case r.Method == http.MethodGet && len(id) == 32: + data, err = svc.GetAgentApplication(ctx, id) + case r.Method == http.MethodGet && id == "": + page, size := 1, 20 + if s := r.URL.Query().Get("page"); s != "" { + page, _ = strconv.Atoi(s) + } + if s := r.URL.Query().Get("page_size"); s != "" { + size, _ = strconv.Atoi(s) + } + edge, parseErr := strconv.ParseUint(r.URL.Query().Get("edge_id"), 10, 64) + if r.URL.Query().Get("edge_id") != "" && parseErr != nil { + writeJSON(w, 400, map[string]any{"code": 400, "message": "invalid connector"}) + return + } + data, err = svc.AgentApplications(ctx, page, size, edge) + case (r.Method == http.MethodPost && id == "") || (r.Method == http.MethodPut && len(id) == 32): + var input controlplane.AgentApplicationInput + d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)) + d.DisallowUnknownFields() + if d.Decode(&input) != nil || d.Decode(new(any)) != io.EOF { + writeJSON(w, 400, map[string]any{"code": 400, "message": "invalid request"}) + return + } + data, err = svc.SaveAgentApplication(ctx, id, input) + case r.Method == http.MethodDelete && len(id) == 32: + err = svc.DeleteAgentApplication(ctx, id) + default: + writeJSON(w, 405, map[string]any{"code": 405, "message": "method not allowed"}) + return + } + if err != nil { + code := 500 + if errors.Is(err, iam.ErrForbidden) { + code = 403 + } else if mapped := kerrors.FromError(mapServiceError(err)); mapped != nil && mapped.Code >= 400 && mapped.Code < 500 { + code = int(mapped.Code) + } + writeJSON(w, code, map[string]any{"code": code, "message": "Agent application unavailable"}) + return + } + writeJSON(w, 200, map[string]any{"code": 200, "message": "success", "data": data}) +} diff --git a/pkg/liaison/manager/web/edge_agent_http.go b/pkg/liaison/manager/web/edge_agent_http.go index 93c99e50..d43fdf1d 100644 --- a/pkg/liaison/manager/web/edge_agent_http.go +++ b/pkg/liaison/manager/web/edge_agent_http.go @@ -56,7 +56,7 @@ func (web *web) handleEdgeAgentHTTP(w http.ResponseWriter, r *http.Request) { data, err = svc.AgentConnectors(ctx) } else { var req proto.EdgeAgentRequest - d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32768)) + d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 256<<10)) d.DisallowUnknownFields() if d.Decode(&req) != nil || d.Decode(new(any)) != io.EOF { writeJSON(w, 400, map[string]any{"code": 400, "message": "invalid agent request"}) diff --git a/pkg/liaison/manager/web/web.go b/pkg/liaison/manager/web/web.go index 17bf94cd..45acce7c 100644 --- a/pkg/liaison/manager/web/web.go +++ b/pkg/liaison/manager/web/web.go @@ -127,6 +127,8 @@ func NewWebServerWithListener(conf *config.Configuration, controlPlane controlpl srv.HandleFunc("/api/v1/edge-agents", web.handleEdgeAgentHTTP) srv.HandleFunc("/api/v1/edge-agents/connectors", web.handleEdgeAgentHTTP) srv.HandleFunc("/api/v1/agent-accesses", web.handleAgentAccessHTTP) + srv.HandleFunc("/api/v1/agent-applications", web.handleAgentApplicationHTTP) + srv.HandlePrefix("/api/v1/agent-applications/", http.HandlerFunc(web.handleAgentApplicationHTTP)) srv.HandlePrefix("/api/v1/agent-accesses/", http.HandlerFunc(web.handleAgentAccessHTTP)) srv.HandleFunc("/api/v1/edges/{id}/installation", web.handleEdgeInstallationHTTP) srv.HandleFunc("/api/v1/edges/{id}/uninstall", web.handleEdgeUninstallHTTP) diff --git a/pkg/liaison/repo/dao/dao.go b/pkg/liaison/repo/dao/dao.go index bb22d8b5..42e65e97 100644 --- a/pkg/liaison/repo/dao/dao.go +++ b/pkg/liaison/repo/dao/dao.go @@ -16,11 +16,16 @@ import ( type Dao interface { ListAgentAccesses(context.Context, uint, int, int, ...model.AgentAccessFilter) ([]model.AgentAccess, int64, error) GetAgentAccess(context.Context, uint, string) (*model.AgentAccess, error) + GetAgentApplication(context.Context, uint, string) (*model.AgentApplication, error) + ListAgentApplications(context.Context, uint, uint64, int, int) ([]model.AgentApplication, int64, error) + SaveAgentApplication(context.Context, *model.AgentApplication, bool) error + DeleteAgentApplication(context.Context, uint, string) error SaveAgentAccess(context.Context, *model.AgentAccess, bool) error DeleteAgentAccess(context.Context, uint, string) error SaveEdgeAgentHistory(context.Context, *model.EdgeAgentHistory) error SaveEdgeAgentHistoryPage(context.Context, *model.EdgeAgentHistoryPage) error PreviousEdgeAgentHistoryPage(context.Context, *model.EdgeAgentHistory, uint64) (*model.EdgeAgentHistoryPage, error) + ListEdgeAgentHistoryPages(context.Context, *model.EdgeAgentHistory, uint64, int, int) ([]model.EdgeAgentHistoryPage, error) GetEdgeAgentHistory(context.Context, *model.EdgeAgentHistory) (*model.EdgeAgentHistory, error) ListEdgeAgentHistories(context.Context, *model.EdgeAgentHistory, int) ([]model.EdgeAgentHistory, int64, error) MutateEdgeAgentHistory(context.Context, *model.EdgeAgentHistory, bool) error @@ -340,6 +345,7 @@ func (d *dao) initDB() error { &model.AgentSession{}, &model.AgentModelSetting{}, &model.AgentAccess{}, + &model.AgentApplication{}, &model.EdgeAgentHistory{}, &model.EdgeAgentHistoryPage{}, &model.AgentAttachment{}, @@ -354,6 +360,9 @@ func (d *dao) initDB() error { if err := d.backfillProxyAccessProtocols(); err != nil { return err } + if err := d.backfillAgentApplications(); err != nil { + return err + } if err := d.backfillWebSSHAuditProtocols(); err != nil { return err } diff --git a/pkg/liaison/repo/dao/dao_agent_access.go b/pkg/liaison/repo/dao/dao_agent_access.go index ee436dab..a27d8024 100644 --- a/pkg/liaison/repo/dao/dao_agent_access.go +++ b/pkg/liaison/repo/dao/dao_agent_access.go @@ -24,26 +24,42 @@ func (d *dao) ListAgentAccesses(ctx context.Context, owner uint, page, size int, return nil, 0, err } counts := d.getDB().Model(&model.EdgeAgentHistory{}).Select("COUNT(*)").Where("owner_id = agent_accesses.owner_id AND access_id = agent_accesses.id AND edge_id = agent_accesses.edge_id AND deleted = ?", false) - err := q.Select("agent_accesses.*, (?) AS session_count", counts).Order("created_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error + names := d.getDB().Model(&model.AgentApplication{}).Select("name").Where("id = agent_accesses.application_id AND owner_id = agent_accesses.owner_id AND edge_id = agent_accesses.edge_id") + err := q.Select("agent_accesses.*, (?) AS session_count, (?) AS application_name", counts, names).Order("created_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error return rows, total, err } func (d *dao) GetAgentAccess(ctx context.Context, owner uint, id string) (*model.AgentAccess, error) { var row model.AgentAccess err := d.getDB().WithContext(ctx).Where("owner_id = ? AND id = ?", owner, id).First(&row).Error + if err == nil && row.ApplicationID != "" { + var app model.AgentApplication + err = d.getDB().WithContext(ctx).Where("id = ? AND owner_id = ? AND edge_id = ? AND kind = ? AND installation_id = ?", row.ApplicationID, owner, row.EdgeID, row.Kind, row.InstallationID).First(&app).Error + } return &row, err } func (d *dao) SaveAgentAccess(ctx context.Context, row *model.AgentAccess, create bool) error { - if create { - return d.getDB().WithContext(ctx).Create(row).Error - } - result := d.getDB().WithContext(ctx).Model(&model.AgentAccess{}).Where("owner_id = ? AND id = ?", row.OwnerID, row.ID).Updates(map[string]any{"name": row.Name, "kind": row.Kind, "edge_id": row.EdgeID, "installation_id": row.InstallationID, "project": row.Project}) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return gorm.ErrRecordNotFound - } - return nil + return d.getDB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if !create { + var existing model.AgentAccess + if err := tx.Where("owner_id = ? AND id = ?", row.OwnerID, row.ID).First(&existing).Error; err != nil { + return err + } + } + if err := bindAgentApplication(tx, row); err != nil { + return err + } + if create { + return tx.Create(row).Error + } + result := tx.Model(&model.AgentAccess{}).Where("owner_id = ? AND id = ?", row.OwnerID, row.ID).Updates(map[string]any{"application_id": row.ApplicationID, "name": row.Name, "kind": row.Kind, "edge_id": row.EdgeID, "installation_id": row.InstallationID, "project": row.Project}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil + }) } func (d *dao) DeleteAgentAccess(ctx context.Context, owner uint, id string) error { return d.getDB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { diff --git a/pkg/liaison/repo/dao/dao_agent_access_test.go b/pkg/liaison/repo/dao/dao_agent_access_test.go index d760249e..5c2a4a33 100644 --- a/pkg/liaison/repo/dao/dao_agent_access_test.go +++ b/pkg/liaison/repo/dao/dao_agent_access_test.go @@ -21,7 +21,7 @@ func TestAgentAccessMigrationAndOwnerIsolation(t *testing.T) { require.NoError(t, err) require.NoError(t, db.Exec(string(up)).Error) require.NoError(t, db.Exec(string(up)).Error) - require.NoError(t, db.AutoMigrate(&model.AgentAccess{}, &model.EdgeAgentHistory{}, &model.EdgeAgentHistoryPage{})) + require.NoError(t, db.AutoMigrate(&model.AgentApplication{}, &model.AgentAccess{}, &model.EdgeAgentHistory{}, &model.EdgeAgentHistoryPage{})) d := &dao{db: db} ctx := context.Background() r := &model.AgentAccess{ID: "entry", OwnerID: 1, EdgeID: 7, Name: "Project", Kind: "codex", InstallationID: "installed", Project: "/project"} diff --git a/pkg/liaison/repo/dao/dao_agent_application.go b/pkg/liaison/repo/dao/dao_agent_application.go new file mode 100644 index 00000000..1bfbeb90 --- /dev/null +++ b/pkg/liaison/repo/dao/dao_agent_application.go @@ -0,0 +1,125 @@ +package dao + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + + "github.com/liaisonio/liaison/pkg/liaison/repo/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ErrAgentApplicationInUse = errors.New("Agent application is referenced") + +func (d *dao) ListAgentApplications(ctx context.Context, owner uint, edge uint64, page, size int) ([]model.AgentApplication, int64, error) { + rows := []model.AgentApplication{} + var total int64 + q := d.getDB().WithContext(ctx).Model(&model.AgentApplication{}).Where("owner_id = ?", owner) + if edge != 0 { + q = q.Where("edge_id = ?", edge) + } + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + counts := d.getDB().Model(&model.AgentAccess{}).Select("COUNT(*)").Where("owner_id = agent_applications.owner_id AND application_id = agent_applications.id") + err := q.Select("agent_applications.*, (?) AS access_count", counts).Order("created_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error + return rows, total, err +} + +func (d *dao) SaveAgentApplication(ctx context.Context, row *model.AgentApplication, create bool) error { + if create { + if err := d.getDB().WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "owner_id"}, {Name: "edge_id"}, {Name: "kind"}, {Name: "installation_id"}}, DoNothing: true}).Create(row).Error; err != nil { + return err + } + var existing model.AgentApplication + err := d.getDB().WithContext(ctx).Where("owner_id = ? AND edge_id = ? AND kind = ? AND installation_id = ?", row.OwnerID, row.EdgeID, row.Kind, row.InstallationID).First(&existing).Error + if err == nil { + *row = existing + } + return err + } + result := d.getDB().WithContext(ctx).Model(&model.AgentApplication{}).Where("owner_id = ? AND id = ?", row.OwnerID, row.ID).Update("name", row.Name) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (d *dao) DeleteAgentApplication(ctx context.Context, owner uint, id string) error { + return d.getDB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var row model.AgentApplication + if err := tx.Where("owner_id = ? AND id = ?", owner, id).First(&row).Error; err != nil { + return err + } + var count int64 + if err := tx.Model(&model.AgentAccess{}).Where("owner_id = ? AND (application_id = ? OR (edge_id = ? AND kind = ? AND installation_id = ?))", owner, id, row.EdgeID, row.Kind, row.InstallationID).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return ErrAgentApplicationInUse + } + return tx.Where("owner_id = ? AND id = ?", owner, id).Delete(&model.AgentApplication{}).Error + }) +} + +// bindAgentApplication runs inside the access save transaction. Explicit references +// must match the legacy fields; older clients register/reuse a scoped installation. +func bindAgentApplication(tx *gorm.DB, access *model.AgentAccess) error { + q := tx.Where("owner_id = ? AND edge_id = ? AND kind = ? AND installation_id = ?", access.OwnerID, access.EdgeID, access.Kind, access.InstallationID) + var app model.AgentApplication + if access.ApplicationID != "" { + return q.Where("id = ?", access.ApplicationID).First(&app).Error + } + var random [16]byte + if _, err := rand.Read(random[:]); err != nil { + return err + } + app = model.AgentApplication{ID: hex.EncodeToString(random[:]), OwnerID: access.OwnerID, EdgeID: access.EdgeID, Kind: access.Kind, InstallationID: access.InstallationID, Name: "Codex"} + if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "owner_id"}, {Name: "edge_id"}, {Name: "kind"}, {Name: "installation_id"}}, DoNothing: true}).Create(&app).Error; err != nil { + return err + } + app = model.AgentApplication{} + if err := q.First(&app).Error; err != nil { + return err + } + access.ApplicationID = app.ID + return nil +} + +// backfillAgentApplications changes metadata only. It never contacts an Edge. +func (d *dao) backfillAgentApplications() error { + return d.db.Transaction(func(tx *gorm.DB) error { + var cursor string + for { + var rows []model.AgentAccess + if err := tx.Where("(application_id IS NULL OR application_id = '') AND id > ?", cursor).Order("id").Limit(100).Find(&rows).Error; err != nil { + return err + } + if len(rows) == 0 { + return nil + } + for i := range rows { + row := &rows[i] + if err := bindAgentApplication(tx, row); err != nil { + return fmt.Errorf("backfill Agent application: %w", err) + } + if err := tx.Model(&model.AgentAccess{}).Where("id = ? AND owner_id = ?", row.ID, row.OwnerID).UpdateColumn("application_id", row.ApplicationID).Error; err != nil { + return err + } + cursor = row.ID + } + } + }) +} + +func (d *dao) GetAgentApplication(ctx context.Context, owner uint, id string) (*model.AgentApplication, error) { + var app model.AgentApplication + err := d.getDB().WithContext(ctx).Where("owner_id = ? AND id = ?", owner, id).First(&app).Error + return &app, err +} diff --git a/pkg/liaison/repo/dao/dao_agent_application_test.go b/pkg/liaison/repo/dao/dao_agent_application_test.go new file mode 100644 index 00000000..6107318a --- /dev/null +++ b/pkg/liaison/repo/dao/dao_agent_application_test.go @@ -0,0 +1,131 @@ +package dao + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/liaisonio/liaison/pkg/liaison/repo/model" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestAgentApplicationMigrationAndBinding(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "applications.db")), &gorm.Config{}) + require.NoError(t, err) + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + t.Cleanup(func() { require.NoError(t, sqlDB.Close()) }) + run := func(name string) { + t.Helper() + script, err := os.ReadFile("../../../../db/migrations/" + name) + require.NoError(t, err) + require.NoError(t, db.Exec(string(script)).Error) + } + run("20260921010000_create_agent_accesses.up.sql") + stamp := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + require.NoError(t, db.Exec("INSERT INTO agent_accesses (id,owner_id,name,kind,edge_id,installation_id,project,updated_at) VALUES (?,?,?,?,?,?,?,?)", "old", 1, "Existing", "codex", 7, "install", "/project", stamp).Error) + run("20260926010000_create_agent_applications.up.sql") + for _, entity := range []any{&model.AgentApplication{}, &model.AgentAccess{}, &model.EdgeAgentHistory{}, &model.EdgeAgentHistoryPage{}} { + require.NoError(t, db.AutoMigrate(entity), "%T", entity) + } + d := &dao{db: db} + ctx := context.Background() + original, err := d.GetAgentAccess(ctx, 1, "old") + require.NoError(t, err) + require.NotEmpty(t, original.ApplicationID) + // Simulate an old client/binary creating a row without an application reference. + legacy := *original + legacy.ID = "legacy" + legacy.ApplicationID = "" + require.NoError(t, db.Create(&legacy).Error) + history := model.EdgeAgentHistory{OwnerID: 1, AccessID: "legacy", EdgeID: 7, SessionID: "native-session", Payload: []byte("encrypted-history"), Revision: 1} + require.NoError(t, db.Create(&history).Error) + require.NoError(t, d.backfillAgentApplications()) + require.NoError(t, d.backfillAgentApplications()) + linked, err := d.GetAgentAccess(ctx, 1, "legacy") + require.NoError(t, err) + require.Equal(t, original.ApplicationID, linked.ApplicationID) + var preserved model.EdgeAgentHistory + require.NoError(t, db.Where("session_id = ?", "native-session").First(&preserved).Error) + require.Equal(t, history.Payload, preserved.Payload) + again, err := d.GetAgentAccess(ctx, 1, "old") + require.NoError(t, err) + require.Equal(t, original.ApplicationID, again.ApplicationID) + require.True(t, again.UpdatedAt.Equal(stamp)) + var wg sync.WaitGroup + errs := make(chan error, 2) + for _, id := range []string{"second", "third"} { + wg.Add(1) + go func(id string) { + defer wg.Done() + errs <- d.SaveAgentAccess(ctx, &model.AgentAccess{ID: id, OwnerID: 1, EdgeID: 7, Kind: "codex", InstallationID: "install", Project: "/other", Name: id}, true) + }(id) + } + wg.Wait() + close(errs) + for err := range errs { + require.NoError(t, err) + } + var count int64 + require.NoError(t, db.Model(&model.AgentApplication{}).Count(&count).Error) + require.EqualValues(t, 1, count) + for _, id := range []string{"second", "third"} { + row, err := d.GetAgentAccess(ctx, 1, id) + require.NoError(t, err) + require.Equal(t, original.ApplicationID, row.ApplicationID) + } + for _, change := range []func(*model.AgentAccess){func(r *model.AgentAccess) { r.OwnerID = 2 }, func(r *model.AgentAccess) { r.EdgeID = 8 }, func(r *model.AgentAccess) { r.InstallationID = "other" }} { + row := *original + row.ID = "invalid" + change(&row) + require.ErrorIs(t, d.SaveAgentAccess(ctx, &row, true), gorm.ErrRecordNotFound) + } + // A failed access insert must roll back the registration, too. + failed := *original + failed.ApplicationID = "" + failed.InstallationID = "new" + require.Error(t, d.SaveAgentAccess(ctx, &failed, true)) + require.NoError(t, db.Model(&model.AgentApplication{}).Count(&count).Error) + require.EqualValues(t, 1, count) + _, err = d.GetAgentApplication(ctx, 2, original.ApplicationID) + require.ErrorIs(t, err, gorm.ErrRecordNotFound) + require.ErrorIs(t, d.DeleteAgentApplication(ctx, 1, original.ApplicationID), ErrAgentApplicationInUse) + require.ErrorIs(t, d.DeleteAgentApplication(ctx, 2, original.ApplicationID), gorm.ErrRecordNotFound) + registered := &model.AgentApplication{ID: "unused", OwnerID: 1, EdgeID: 7, Kind: "codex", InstallationID: "unused-install", Name: "Standalone"} + require.NoError(t, d.SaveAgentApplication(ctx, registered, true)) + registered.Name = "Renamed" + require.NoError(t, d.SaveAgentApplication(ctx, registered, false)) + apps, total, err := d.ListAgentApplications(ctx, 1, 7, 1, 100) + require.NoError(t, err) + require.EqualValues(t, 2, total) + require.Len(t, apps, 2) + for _, app := range apps { + if app.ID == original.ApplicationID { + require.EqualValues(t, 4, app.AccessCount) + } + } + require.NoError(t, d.DeleteAgentApplication(ctx, 1, registered.ID)) + // Tampered linkage fails closed on the runtime's access lookup. + require.NoError(t, db.Model(&model.AgentAccess{}).Where("id = ?", "second").UpdateColumn("installation_id", "tampered").Error) + _, err = d.GetAgentAccess(ctx, 1, "second") + require.ErrorIs(t, err, gorm.ErrRecordNotFound) + // Removing an access leaves the reusable application and other accesses intact. + require.NoError(t, d.DeleteAgentAccess(ctx, 1, "old")) + _, err = d.GetAgentApplication(ctx, 1, original.ApplicationID) + require.NoError(t, err) + _, err = d.GetAgentAccess(ctx, 1, "third") + require.NoError(t, err) + run("20260926010000_create_agent_applications.down.sql") + require.False(t, db.Migrator().HasTable(&model.AgentApplication{})) + require.False(t, db.Migrator().HasColumn(&model.AgentAccess{}, "application_id")) + run("20260926010000_create_agent_applications.up.sql") + require.NoError(t, d.backfillAgentApplications()) + _, err = d.GetAgentAccess(ctx, 1, "third") + require.NoError(t, err) +} diff --git a/pkg/liaison/repo/dao/dao_edge_agent_history_page.go b/pkg/liaison/repo/dao/dao_edge_agent_history_page.go index c0c383c0..28e65af8 100644 --- a/pkg/liaison/repo/dao/dao_edge_agent_history_page.go +++ b/pkg/liaison/repo/dao/dao_edge_agent_history_page.go @@ -2,11 +2,43 @@ package dao import ( "context" + "errors" "github.com/liaisonio/liaison/pkg/liaison/repo/model" "gorm.io/gorm" "gorm.io/gorm/clause" ) +// ListEdgeAgentHistoryPages streams a bounded index range rather than allocating +// every payload up front. The connection is released before authorization rechecks. +func (d *dao) ListEdgeAgentHistoryPages(ctx context.Context, scope *model.EdgeAgentHistory, before uint64, limit, maxBytes int) ([]model.EdgeAgentHistoryPage, error) { + if limit < 1 || limit > 20 || maxBytes < 512<<10 || maxBytes > 2<<20 { + return nil, errors.New("invalid history page budget") + } + query := d.getDB().WithContext(ctx).Model(&model.EdgeAgentHistoryPage{}).Where("owner_id = ? AND access_id = ? AND edge_id = ? AND session_id = ? AND window < ?", scope.OwnerID, scope.AccessID, scope.EdgeID, scope.SessionID, before).Order("window DESC").Limit(limit) + rows, err := query.Rows() + if err != nil { + return nil, err + } + defer rows.Close() + var pages []model.EdgeAgentHistoryPage + total := 0 + for rows.Next() { + var page model.EdgeAgentHistoryPage + if err := query.ScanRows(rows, &page); err != nil { + return nil, err + } + if len(page.Payload) > maxBytes { + return nil, errors.New("history page exceeds budget") + } + if total+len(page.Payload) > maxBytes { + break + } + pages = append(pages, page) + total += len(page.Payload) + } + return pages, rows.Err() +} + func (d *dao) SaveEdgeAgentHistoryPage(ctx context.Context, page *model.EdgeAgentHistoryPage) error { return d.getDB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { scope := &model.EdgeAgentHistory{OwnerID: page.OwnerID, AccessID: page.AccessID, EdgeID: page.EdgeID, SessionID: page.SessionID} diff --git a/pkg/liaison/repo/dao/dao_edge_agent_history_test.go b/pkg/liaison/repo/dao/dao_edge_agent_history_test.go index a1dc3822..2c9d18f1 100644 --- a/pkg/liaison/repo/dao/dao_edge_agent_history_test.go +++ b/pkg/liaison/repo/dao/dao_edge_agent_history_test.go @@ -1,6 +1,7 @@ package dao import ( + "bytes" "context" "fmt" "github.com/liaisonio/liaison/pkg/liaison/repo/model" @@ -13,6 +14,49 @@ import ( "time" ) +func TestHistoryBatchLimitsCursorAndScope(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "batch.db")), &gorm.Config{}) + require.NoError(t, err) + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + t.Cleanup(func() { require.NoError(t, sqlDB.Close()) }) + require.NoError(t, db.AutoMigrate(&model.EdgeAgentHistoryPage{})) + d := &dao{db: db} + ctx := context.Background() + scope := &model.EdgeAgentHistory{OwnerID: 1, AccessID: "access", EdgeID: 7, SessionID: "session"} + for i := uint64(0); i < 45; i++ { + require.NoError(t, db.Create(&model.EdgeAgentHistoryPage{OwnerID: 1, AccessID: "access", EdgeID: 7, SessionID: "session", Window: i, Payload: []byte("small")}).Error) + } + pages, err := d.ListEdgeAgentHistoryPages(ctx, scope, 45, 20, 2<<20) + require.NoError(t, err) + require.Len(t, pages, 20) + require.EqualValues(t, 44, pages[0].Window) + require.EqualValues(t, 25, pages[19].Window) + pages, err = d.ListEdgeAgentHistoryPages(ctx, scope, 25, 20, 2<<20) + require.NoError(t, err) + require.Len(t, pages, 20) + require.EqualValues(t, 24, pages[0].Window) + pages, err = d.ListEdgeAgentHistoryPages(ctx, scope, 5, 20, 2<<20) + require.NoError(t, err) + require.Len(t, pages, 5) + require.Zero(t, pages[4].Window) + for _, foreign := range []model.EdgeAgentHistory{{OwnerID: 2, AccessID: "access", EdgeID: 7, SessionID: "session"}, {OwnerID: 1, AccessID: "other", EdgeID: 7, SessionID: "session"}, {OwnerID: 1, AccessID: "access", EdgeID: 8, SessionID: "session"}, {OwnerID: 1, AccessID: "access", EdgeID: 7, SessionID: "other"}} { + pages, err = d.ListEdgeAgentHistoryPages(ctx, &foreign, 45, 20, 2<<20) + require.NoError(t, err) + require.Empty(t, pages) + } + require.NoError(t, db.Model(&model.EdgeAgentHistoryPage{}).Where("window >= ?", 40).Update("payload", bytes.Repeat([]byte("x"), 500<<10)).Error) + pages, err = d.ListEdgeAgentHistoryPages(ctx, scope, 45, 20, (2<<20)-1024) + require.NoError(t, err) + require.Len(t, pages, 4) + pages, err = d.ListEdgeAgentHistoryPages(ctx, scope, pages[3].Window, 20, (2<<20)-1024) + require.NoError(t, err) + require.EqualValues(t, 40, pages[0].Window) + _, err = d.ListEdgeAgentHistoryPages(ctx, scope, 45, 21, 2<<20) + require.Error(t, err) +} + func TestEdgeAgentHistoryDurabilityScopeAndTombstone(t *testing.T) { file := filepath.Join(t.TempDir(), "history.db") db, err := gorm.Open(sqlite.Open(file), &gorm.Config{}) @@ -21,7 +65,7 @@ func TestEdgeAgentHistoryDurabilityScopeAndTombstone(t *testing.T) { require.NoError(t, err) require.NoError(t, db.Exec(string(up)).Error) require.NoError(t, db.Exec(string(up)).Error) - require.NoError(t, db.AutoMigrate(&model.AgentAccess{}, &model.EdgeAgentHistory{}, &model.EdgeAgentHistoryPage{})) + require.NoError(t, db.AutoMigrate(&model.AgentApplication{}, &model.AgentAccess{}, &model.EdgeAgentHistory{}, &model.EdgeAgentHistoryPage{})) d := &dao{db: db} ctx := context.Background() require.NoError(t, d.SaveAgentAccess(ctx, &model.AgentAccess{ID: "access", OwnerID: 1, EdgeID: 7}, true)) diff --git a/pkg/liaison/repo/model/model_agent_access.go b/pkg/liaison/repo/model/model_agent_access.go index 77bf0428..036578cb 100644 --- a/pkg/liaison/repo/model/model_agent_access.go +++ b/pkg/liaison/repo/model/model_agent_access.go @@ -9,14 +9,16 @@ type AgentAccessFilter struct { // AgentAccess stores an owner's reusable entry, not a running Agent session. type AgentAccess struct { - ID string `gorm:"primaryKey;size:32" json:"id"` - OwnerID uint `gorm:"not null;index" json:"-"` - Name string `gorm:"not null;size:120" json:"name"` - Kind string `gorm:"not null;size:32" json:"kind"` - EdgeID uint64 `gorm:"not null;index" json:"edge_id"` - InstallationID string `gorm:"not null;size:32" json:"installation_id"` - Project string `gorm:"not null" json:"project"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - SessionCount int64 `gorm:"->;-:migration" json:"session_count"` + ApplicationName string `gorm:"->;-:migration" json:"application_name"` + ApplicationID string `gorm:"size:32;index;default:''" json:"application_id"` + ID string `gorm:"primaryKey;size:32" json:"id"` + OwnerID uint `gorm:"not null;index" json:"-"` + Name string `gorm:"not null;size:120" json:"name"` + Kind string `gorm:"not null;size:32" json:"kind"` + EdgeID uint64 `gorm:"not null;index" json:"edge_id"` + InstallationID string `gorm:"not null;size:32" json:"installation_id"` + Project string `gorm:"not null" json:"project"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + SessionCount int64 `gorm:"->;-:migration" json:"session_count"` } diff --git a/pkg/liaison/repo/model/model_agent_application.go b/pkg/liaison/repo/model/model_agent_application.go new file mode 100644 index 00000000..d8220c8c --- /dev/null +++ b/pkg/liaison/repo/model/model_agent_application.go @@ -0,0 +1,18 @@ +package model + +import "time" + +// AgentApplication registers an existing installation, not a running process. +type AgentApplication struct { + AccessCount int64 `gorm:"->;-:migration" json:"access_count"` + ID string `gorm:"primaryKey;size:32" json:"id"` + OwnerID uint `gorm:"not null;uniqueIndex:idx_agent_installation" json:"-"` + EdgeID uint64 `gorm:"not null;uniqueIndex:idx_agent_installation" json:"edge_id"` + Kind string `gorm:"not null;size:32;uniqueIndex:idx_agent_installation" json:"kind"` + InstallationID string `gorm:"not null;size:32;uniqueIndex:idx_agent_installation" json:"installation_id"` + Name string `gorm:"not null;size:120" json:"name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (AgentApplication) TableName() string { return "agent_applications" } diff --git a/pkg/proto/edge_agent.go b/pkg/proto/edge_agent.go index 699613e8..38cdfe15 100644 --- a/pkg/proto/edge_agent.go +++ b/pkg/proto/edge_agent.go @@ -10,26 +10,29 @@ import ( const RPCEdgeAgent = "edge_agent_v1" type EdgeAgentRequest struct { - HistoryBefore string `json:"history_before,omitempty"` - EdgeID uint64 `json:"edge_id"` - Action string `json:"action"` - InstallationID string `json:"installation_id,omitempty"` - Project string `json:"project,omitempty"` - SessionID string `json:"session_id,omitempty"` - Text string `json:"text,omitempty"` - SkillID string `json:"skill_id,omitempty"` - AccessID string `json:"access_id,omitempty"` - Directory string `json:"directory,omitempty"` - WorkingDirectory string `json:"working_directory,omitempty"` - Cursor string `json:"cursor,omitempty"` - Model string `json:"model,omitempty"` - Title string `json:"title,omitempty"` - HistoryPage string `json:"history_page,omitempty"` - ApprovalID string `json:"approval_id,omitempty"` - Decision string `json:"decision,omitempty"` - PermissionMode string `json:"permission_mode,omitempty"` - InputID string `json:"input_id,omitempty"` - Answers []AgentInputAnswer `json:"answers,omitempty"` + File *AgentFileOperation `json:"file,omitempty"` + Attachments []string `json:"attachments,omitempty"` + HistoryLimit string `json:"history_limit,omitempty"` + HistoryBefore string `json:"history_before,omitempty"` + EdgeID uint64 `json:"edge_id"` + Action string `json:"action"` + InstallationID string `json:"installation_id,omitempty"` + Project string `json:"project,omitempty"` + SessionID string `json:"session_id,omitempty"` + Text string `json:"text,omitempty"` + SkillID string `json:"skill_id,omitempty"` + AccessID string `json:"access_id,omitempty"` + Directory string `json:"directory,omitempty"` + WorkingDirectory string `json:"working_directory,omitempty"` + Cursor string `json:"cursor,omitempty"` + Model string `json:"model,omitempty"` + Title string `json:"title,omitempty"` + HistoryPage string `json:"history_page,omitempty"` + ApprovalID string `json:"approval_id,omitempty"` + Decision string `json:"decision,omitempty"` + PermissionMode string `json:"permission_mode,omitempty"` + InputID string `json:"input_id,omitempty"` + Answers []AgentInputAnswer `json:"answers,omitempty"` } type EdgeAgentRPCRequest struct { ResetRevision uint64 `json:"reset_revision,omitempty"` @@ -57,15 +60,18 @@ type AgentInstallation struct { Source string `json:"source"` } type EdgeAgentMessage struct { - Role string `json:"role"` - Text string `json:"text"` - ItemID string `json:"-"` + Attachments []AgentFile `json:"attachments,omitempty"` + Role string `json:"role"` + Text string `json:"text"` + ItemID string `json:"-"` } type AgentDirectory struct { Name string `json:"name"` Path string `json:"path"` } type AgentActivity struct { + Label string `json:"label,omitempty"` + Plan []AgentPlanStep `json:"plan,omitempty"` ID string `json:"id"` Kind string `json:"kind"` Status string `json:"status"` @@ -78,6 +84,10 @@ type AgentActivity struct { Changes []AgentFileChange `json:"changes,omitempty"` Truncated bool `json:"truncated,omitempty"` } +type AgentPlanStep struct { + Step string `json:"step"` + Status string `json:"status"` +} type AgentFileChange struct { Path string `json:"path"` Kind string `json:"kind"` @@ -106,6 +116,16 @@ type AgentInputAnswer struct { Answers []string `json:"answers"` } type EdgeAgentResult struct { + FilesAvailable bool `json:"files_available,omitempty"` + FilesUploadAvailable bool `json:"files_upload_available,omitempty"` + Files []AgentFile `json:"files,omitempty"` + File *AgentFile `json:"file,omitempty"` + TransferID string `json:"transfer_id,omitempty"` + FileData []byte `json:"file_data,omitempty"` + FileOffset int64 `json:"file_offset,omitempty"` + FileDone bool `json:"file_done,omitempty"` + HistoryPages []EdgeAgentResult `json:"history_pages,omitempty"` + HistoryBefore string `json:"history_before,omitempty"` Window uint64 `json:"window,omitempty"` HistoryWindowing bool `json:"history_windowing,omitempty"` InputRequests []AgentInputRequest `json:"input_requests,omitempty"` @@ -147,10 +167,12 @@ type EdgeAgentResult struct { // AgentApproval is a display-only projection. Native RPC IDs remain on Edge. type AgentApproval struct { - ID string `json:"id"` - Command string `json:"command"` - Directory string `json:"directory"` - Reason string `json:"reason"` + Kind string `json:"kind,omitempty"` + Changes []AgentFileChange `json:"changes,omitempty"` + ID string `json:"id"` + Command string `json:"command"` + Directory string `json:"directory"` + Reason string `json:"reason"` } type AgentModel struct { ID string `json:"id"` @@ -175,6 +197,15 @@ type AgentSkill struct { } func (r EdgeAgentRequest) Valid() bool { + if !r.validFiles() { + return false + } + if r.HistoryLimit != "" { + limit, err := strconv.Atoi(r.HistoryLimit) + if r.Action != "transcript" || err != nil || limit < 1 || limit > 20 { + return false + } + } if r.Action == "transcript" { before, err := strconv.ParseUint(r.HistoryBefore, 10, 63) if err != nil || before == 0 || len(r.AccessID) != 32 { @@ -247,6 +278,8 @@ func (r EdgeAgentRequest) Valid() bool { return false } switch r.Action { + case "file_list", "file_begin", "file_write", "file_commit", "file_cancel", "file_read": + return len(r.AccessID) == 32 && len(r.SessionID) == 32 && r.Text == "" && r.Project == "" && r.InstallationID == "" case "sessions": return len(r.AccessID) == 32 && r.SessionID == "" && r.Text == "" && r.Project == "" && r.InstallationID == "" case "directories": @@ -260,7 +293,7 @@ func (r EdgeAgentRequest) Valid() bool { case "poll", "snapshot", "watch", "stop", "interrupt", "models", "model", "rename", "delete", "discard", "approve", "permissions", "answer", "transcript": return len(r.SessionID) == 32 && r.Text == "" && r.Project == "" && r.InstallationID == "" case "send": - return len(r.SessionID) == 32 && r.Text != "" && r.Project == "" && r.InstallationID == "" + return len(r.SessionID) == 32 && (r.Text != "" || len(r.Attachments) > 0) && r.Project == "" && r.InstallationID == "" } return false } diff --git a/pkg/proto/edge_agent_files.go b/pkg/proto/edge_agent_files.go new file mode 100644 index 00000000..faea33d4 --- /dev/null +++ b/pkg/proto/edge_agent_files.go @@ -0,0 +1,59 @@ +package proto + +import ( + "strings" + "unicode" + "unicode/utf8" +) + +const AgentFileChunk = 128 << 10 +const AgentFileLimit = 20 << 20 + +type AgentFileOperation struct { + Path string `json:"path,omitempty"` + Name string `json:"name,omitempty"` + TransferID string `json:"transfer_id,omitempty"` + Offset int64 `json:"offset,omitempty"` + Size int64 `json:"size,omitempty"` + Data []byte `json:"data,omitempty"` +} +type AgentFile struct { + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` + Directory bool `json:"directory,omitempty"` + MediaType string `json:"media_type,omitempty"` +} + +func (r EdgeAgentRequest) validFiles() bool { + if len(r.Attachments) > 8 || (len(r.Attachments) > 0 && (r.Action != "send" || len(r.AccessID) != 32)) { + return false + } + seen := map[string]bool{} + for _, p := range r.Attachments { + if p == "" || len(p) > 4096 || !utf8.ValidString(p) || strings.ContainsFunc(p, unicode.IsControl) || seen[p] { + return false + } + seen[p] = true + } + if !strings.HasPrefix(r.Action, "file_") { + return r.File == nil + } + f := r.File + if f == nil || len(f.Path) > 4096 || !utf8.ValidString(f.Path) || strings.ContainsFunc(f.Path, unicode.IsControl) || f.Size < 0 || f.Size > AgentFileLimit || f.Offset < 0 || f.Offset > AgentFileLimit || len(f.Data) > AgentFileChunk { + return false + } + switch r.Action { + case "file_list": + return f.Name == "" && f.TransferID == "" && len(f.Data) == 0 && f.Offset == 0 && f.Size == 0 + case "file_begin": + return f.Path == "" && f.TransferID == "" && len(f.Data) == 0 && f.Offset == 0 && len(f.Name) > 0 && len(f.Name) <= 180 && utf8.ValidString(f.Name) && !strings.ContainsAny(f.Name, "/\\:") && !strings.ContainsFunc(f.Name, unicode.IsControl) && f.Name != "." && f.Name != ".." + case "file_write": + return len(f.TransferID) == 32 && f.Path == "" && f.Name == "" && f.Size == 0 && len(f.Data) > 0 + case "file_commit", "file_cancel": + return len(f.TransferID) == 32 && f.Path == "" && f.Name == "" && f.Size == 0 && f.Offset == 0 && len(f.Data) == 0 + case "file_read": + return f.Name == "" && f.Size == 0 && len(f.Data) == 0 && ((f.Path != "" && f.TransferID == "" && f.Offset == 0) || (f.Path == "" && len(f.TransferID) == 32)) + } + return false +} diff --git a/pkg/proto/edge_agent_files_test.go b/pkg/proto/edge_agent_files_test.go new file mode 100644 index 00000000..526f2292 --- /dev/null +++ b/pkg/proto/edge_agent_files_test.go @@ -0,0 +1,33 @@ +package proto + +import ( + "github.com/stretchr/testify/require" + "strings" + "testing" +) + +func TestAgentFileValidation(t *testing.T) { + base := EdgeAgentRequest{Action: "file_begin", AccessID: strings.Repeat("a", 32), SessionID: strings.Repeat("b", 32)} + for _, name := range []string{"../a", "a/b", "a\\b", "..", "a\x00b", "a\nb", "a:b"} { + r := base + r.File = &AgentFileOperation{Name: name} + require.False(t, r.Valid(), name) + } + base.File = &AgentFileOperation{Name: "report.txt", Size: AgentFileLimit} + require.True(t, base.Valid()) + base.File.Size++ + require.False(t, base.Valid()) + base.Action = "file_write" + base.File = &AgentFileOperation{TransferID: strings.Repeat("c", 32), Data: make([]byte, AgentFileChunk+1)} + require.False(t, base.Valid()) + base.File.Data = base.File.Data[:AgentFileChunk] + require.True(t, base.Valid()) + base.Action = "send" + base.File = nil + base.Attachments = []string{".liaison-attachments/test/file"} + require.True(t, base.Valid()) + base.Attachments = append(base.Attachments, base.Attachments[0]) + require.False(t, base.Valid()) + base.Action = "poll" + require.False(t, base.Valid()) +} diff --git a/pkg/proto/edge_agent_test.go b/pkg/proto/edge_agent_test.go index f975fd04..d92029cd 100644 --- a/pkg/proto/edge_agent_test.go +++ b/pkg/proto/edge_agent_test.go @@ -13,6 +13,10 @@ func TestEdgeAgentDirectoryAndWatchValidation(t *testing.T) { valid bool }{ {"home directory", EdgeAgentRequest{Action: "directories"}, true}, + {"batch history", EdgeAgentRequest{Action: "transcript", AccessID: id, SessionID: id, HistoryBefore: "30", HistoryLimit: "20"}, true}, + {"zero history limit", EdgeAgentRequest{Action: "transcript", AccessID: id, SessionID: id, HistoryBefore: "30", HistoryLimit: "0"}, false}, + {"oversized history limit", EdgeAgentRequest{Action: "transcript", AccessID: id, SessionID: id, HistoryBefore: "30", HistoryLimit: "21"}, false}, + {"history limit on poll", EdgeAgentRequest{Action: "poll", SessionID: id, HistoryLimit: "20"}, false}, {"sessions scoped to access", EdgeAgentRequest{Action: "sessions", AccessID: id}, true}, {"sessions requires access", EdgeAgentRequest{Action: "sessions"}, false}, {"sessions rejects cursor", EdgeAgentRequest{Action: "sessions", AccessID: id, Cursor: "1"}, false}, diff --git a/web/e2e/access-empty.cjs b/web/e2e/access-empty.cjs index e7942f56..37841eb6 100644 --- a/web/e2e/access-empty.cjs +++ b/web/e2e/access-empty.cjs @@ -22,6 +22,9 @@ const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright'); const url=category==='agent'?`${base}/e2e/edge-agent.html`:`${base}/e2e/product-polish.html?entry=${encodeURIComponent('/proxy?category='+category)}`; await page.goto(url); const empty=page.locator('.liaison-access-empty');await empty.getByRole('button',{name:zh?'新建访问':'Create access',exact:true}).waitFor(); + assert.equal(await empty.locator('.lucide-arrow-right').count(),0,'Creation actions have no navigation arrow'); + const spacing=await empty.evaluate(el=>{const css=getComputedStyle(el);return {top:parseFloat(css.paddingTop),bottom:parseFloat(css.paddingBottom)};}); + assert(spacing.bottom>=48&&spacing.bottom>spacing.top,'Empty states retain generous bottom breathing room'); assert.equal(await page.locator('.liaison-list-header').getByRole('button',{name:zh?'新建访问':'Create access',exact:true}).count(),0); if(category==='llm'){ assert.equal(await empty.locator('details').count(),0); diff --git a/web/e2e/agent-applications.cjs b/web/e2e/agent-applications.cjs new file mode 100644 index 00000000..c0500491 --- /dev/null +++ b/web/e2e/agent-applications.cjs @@ -0,0 +1,44 @@ +const assert=require('node:assert/strict'); +const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright'); +(async()=>{const browser=await chromium.launch();try{ + for(const locale of ['zh-CN','en-US'])for(const dark of [false,true])for(const width of [1280,390]){ + const page=await browser.newPage({viewport:{width,height:900}}),errors=[];let apps=[],accesses=[],discoveries=0; + const zh=locale==='zh-CN',tr=(a,b)=>zh?a:b; + page.on('pageerror',e=>errors.push(e.message)); + await page.addInitScript(({locale,dark})=>{localStorage.setItem('liaison-locale',locale);document.addEventListener('DOMContentLoaded',()=>{document.documentElement.classList.toggle('dark',dark);document.documentElement.classList.toggle('light',!dark);});},{locale,dark}); + await page.route('**/api/v1/edge-agents**',route=>{if(route.request().method()==='GET')return route.fulfill({json:{data:[{id:6,name:'Mac mini',device:'Mac-mini.local',online:true}]}});assert.equal(route.request().postDataJSON().action,'discover','Registration never starts a session');discoveries++;return route.fulfill({json:{data:{version:1,status:'ok',installations:[{id:'a'.repeat(32),kind:'codex',path:'/opt/homebrew/bin/codex'}]}}});}); + await page.route('**/api/v1/agent-applications**',route=>{const req=route.request();let data; + if(req.method()==='POST'){data={...req.postDataJSON(),id:'b'.repeat(32),access_count:0};apps=[data];} + else if(req.method()==='PUT'){apps[0].name=req.postDataJSON().name;data=apps[0];} + else if(req.method()==='DELETE'){assert.equal(apps[0].access_count,0);apps=[];data=null;} + else data={items:apps,total:apps.length};return route.fulfill({json:{data}}); + }); + await page.route('**/api/v1/agent-accesses**',route=>{assert.equal(route.request().method(),'POST');const data={...route.request().postDataJSON(),id:'c'.repeat(32)};assert.equal(data.application_id,apps[0].id);accesses.push(data);apps[0].access_count++;return route.fulfill({json:{data}});}); + await page.goto((process.env.E2E_UI_URL||'http://127.0.0.1:5301')+'/e2e/agent-applications.html'); + await page.getByRole('button',{name:tr('新建应用','Create application'),exact:true}).click(); + await page.getByRole('textbox',{name:tr('应用名称','Application name')}).fill('Mac Codex'); + await page.getByRole('button',{name:tr('发现已安装的 Agent','Discover installed Agents')}).click(); + await page.getByRole('combobox',{name:tr('Codex 安装','Codex installation')}).waitFor(); + await page.screenshot({path:`/tmp/agent-app-registration-${locale}-${dark}-${width}.png`,fullPage:true}); + await page.getByRole('button',{name:tr('保存','Save'),exact:true}).click(); + await page.getByRole('cell',{name:'Mac Codex',exact:true}).waitFor();assert.equal(discoveries,1); + for(let i=0;i<2;i++){ + await page.getByRole('button',{name:tr('创建访问','Create access'),exact:true}).click(); + const dialog=page.getByRole('dialog'); + assert.equal(await dialog.getByRole('combobox',{name:tr('应用来源','Application source')}).inputValue(),'existing'); + await dialog.getByRole('option',{name:'Mac Codex',exact:true}).waitFor({state:'attached'}); + await dialog.getByRole('textbox',{name:tr('项目目录','Project directory')}).fill('/Users/local/project'); + await dialog.getByRole('button',{name:tr('创建访问','Create access'),exact:true}).click();await dialog.waitFor({state:'hidden'}); + } + assert.equal(discoveries,1,'Reusing an application does not discover or launch');assert.equal(accesses.length,2); + assert(await page.getByRole('button',{name:tr('删除','Delete'),exact:true}).isDisabled()); + await page.getByRole('button',{name:tr('编辑','Edit'),exact:true}).click(); + await page.getByRole('textbox',{name:tr('应用名称','Application name')}).fill('Renamed Codex'); + await page.getByRole('button',{name:tr('保存','Save'),exact:true}).click(); + await page.getByRole('cell',{name:'Renamed Codex',exact:true}).waitFor(); + assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1),'No document overflow'); + await page.screenshot({path:`/tmp/agent-app-list-${locale}-${dark}-${width}.png`,fullPage:true}); + assert.deepEqual(errors,[]);await page.close(); + } + console.log('PASS 8 Agent application registration/reuse/rename/guard combinations'); +}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/web/e2e/agent-applications.html b/web/e2e/agent-applications.html new file mode 100644 index 00000000..2569e0ba --- /dev/null +++ b/web/e2e/agent-applications.html @@ -0,0 +1 @@ +
diff --git a/web/e2e/agent-applications.tsx b/web/e2e/agent-applications.tsx new file mode 100644 index 00000000..e8fcb687 --- /dev/null +++ b/web/e2e/agent-applications.tsx @@ -0,0 +1,5 @@ +import {createRoot} from 'react-dom/client'; +import {BrowserRouter} from 'react-router-dom'; +import AgentApplications from '../src/pages/EdgeAgent/Applications'; +import '../src/styles/index.css'; +createRoot(document.getElementById('root')!).render(
); diff --git a/web/e2e/agent-files.cjs b/web/e2e/agent-files.cjs new file mode 100644 index 00000000..8b096b5f --- /dev/null +++ b/web/e2e/agent-files.cjs @@ -0,0 +1,2 @@ +process.env.E2E_FILES_ONLY='1'; +require('./edge-agent.cjs'); diff --git a/web/e2e/agent-transcript.cjs b/web/e2e/agent-transcript.cjs new file mode 100644 index 00000000..137a43a1 --- /dev/null +++ b/web/e2e/agent-transcript.cjs @@ -0,0 +1,52 @@ +const assert=require('node:assert/strict'); +const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright'); +(async()=>{ + const browser=await chromium.launch(process.env.CHROME_EXECUTABLE?{executablePath:process.env.CHROME_EXECUTABLE}:undefined); + try{ + for(const locale of ['zh-CN','en-US'])for(const dark of [false,true])for(const width of [1280,390]){ + const page=await browser.newPage({viewport:{width,height:900}});const errors=[];const calls=[];let fail=false,delay=0,byteLimit=20; + page.on('pageerror',e=>errors.push(e.message)); + await page.addInitScript(({locale,dark})=>{localStorage.setItem('liaison-locale',locale);document.addEventListener('DOMContentLoaded',()=>{document.documentElement.className=dark?'dark':'light';});},{locale,dark}); + await page.route('**/api/v1/edge-agents',async route=>{ + const body=route.request().postDataJSON();calls.push(body); + if(delay)await new Promise(resolve=>setTimeout(resolve,delay)); + if(fail){fail=false;return route.fulfill({status:503,json:{message:'Unavailable'}});} + const pages=[];for(let i=Number(body.history_before)-1;i>=0&&pages.lengthpage.getByRole('button',{name:zh?'加载更早记录':'Load earlier messages',exact:true}); + const count=async n=>page.waitForFunction(n=>document.querySelectorAll('.agent-message.is-user').length===n,n); + await page.goto((process.env.E2E_UI_URL||'http://127.0.0.1:5298')+'/e2e/agent-transcript.html'); + await count(20);assert.equal(calls.length,1);assert.equal(calls[0].history_limit,'19'); + const transcript=page.locator('.edge-agent-messages'); + assert(await transcript.evaluate(e=>e.scrollWidth<=e.clientWidth+1),'Running long command cannot widen transcript'); + assert(await page.locator('.edge-agent-activity li.has-detail > details').last().evaluate(el=>el.open),'Running command details are automatically visible'); + assert(await transcript.evaluate(e=>e.scrollWidth<=e.clientWidth+1),'Expanded command output cannot widen transcript'); + await page.screenshot({path:`/tmp/agent-running-width-${locale}-${dark}-${width}.png`,fullPage:true}); + assert.equal((await page.locator('.agent-message.is-user').first().innerText()).split('\n').at(-1),'Round 26'); + await page.locator('.edge-agent-messages').evaluate(el=>{el.scrollTop=0;}); + const anchor=page.locator('.agent-message.is-user').filter({hasText:'Round 26'});const y=(await anchor.boundingBox()).y; + await earlier().click();await count(40);assert.equal(calls.at(-1).history_limit,'20'); + assert(Math.abs((await anchor.boundingBox()).y-y)<4,'Prepending history must preserve the reading position'); + await page.locator('.edge-agent-messages').evaluate(el=>{el.scrollTop=0;}); + fail=true;await earlier().click();await page.getByRole('button',{name:zh?'重试':'Retry',exact:true}).waitFor();assert.equal(await page.locator('.agent-message.is-user').count(),40); + await page.getByRole('button',{name:zh?'重试':'Retry',exact:true}).click();await count(46);assert.equal(await earlier().count(),0); + await page.getByRole('button',{name:'Next round',exact:true}).click();await count(47); + await page.waitForTimeout(250);assert.equal(await page.locator('.agent-message.is-user').count(),47,'Live rollover must not replace earlier messages'); + assert.equal(await earlier().count(),0); + await page.screenshot({path:`/tmp/agent-continuous-${locale}-${dark}-${width}.png`,fullPage:true}); + assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)); + await page.getByRole('button',{name:'Remote rounds',exact:true}).click(); + await page.getByRole('button',{name:zh?'加载中间记录':'Load missing messages',exact:true}).click();await count(72); + assert.equal(await page.getByRole('button',{name:zh?'加载中间记录':'Load missing messages',exact:true}).count(),0); + await page.reload();await count(20);assert.equal(calls.at(-1).history_limit,'19'); + byteLimit=4;await page.reload();await count(5);byteLimit=20; + await page.locator('.edge-agent-messages').evaluate(el=>{el.scrollTop=0;});await earlier().click();await count(25); + // Ignore a delayed history response after navigating to another session. + delay=400;await page.locator('.edge-agent-messages').evaluate(el=>{el.scrollTop=0;});await earlier().click(); + await page.getByRole('button',{name:'Switch session',exact:true}).click();await count(1);await page.waitForTimeout(600);await count(1); + assert.deepEqual(errors,[]);await page.close(); + } + console.log('PASS continuous history: 20-round initial load, cursor batches, stable scroll, retry, rollover, reload and session isolation across 8 UI combinations'); + }finally{await browser.close();} +})().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/web/e2e/agent-transcript.html b/web/e2e/agent-transcript.html new file mode 100644 index 00000000..a91ce6ec --- /dev/null +++ b/web/e2e/agent-transcript.html @@ -0,0 +1 @@ +Agent transcript fixture
diff --git a/web/e2e/agent-transcript.tsx b/web/e2e/agent-transcript.tsx new file mode 100644 index 00000000..c38f7faa --- /dev/null +++ b/web/e2e/agent-transcript.tsx @@ -0,0 +1,14 @@ +import {useState} from 'react'; +import {createRoot} from 'react-dom/client'; +import {Transcript} from '../src/pages/EdgeAgent/Transcript'; +import type {AgentSnapshot} from '../src/services/edgeAgent'; +import '../src/styles/index.css'; +import '../src/components/AgentWorkspace/index.less'; +import '../src/pages/EdgeAgent/index.less'; +import '../src/pages/EdgeAgent/interaction.less'; +function Fixture(){ + const [window,setWindow]=useState(45),[id,setID]=useState('s'.repeat(32)); + const session:AgentSnapshot={version:1,status:'ok',session_id:id,window,running:true,closed:false,messages:[{role:'user',text:`Round ${window}`},{role:'assistant',text:'Latest answer. '+('检查 `operations/map` 与文件的对应关系。 Verify multipart metadata. '.repeat(30))}],activities:[{id:'long-command',kind:'commandExecution',status:'running',message_index:1,duration_ms:0,command:'/bin/bash -lc '+('cat internal/edge/data/schema/multipart.go && '.repeat(100)),output:'x'.repeat(5000)}]}; + return

Empty

; +} +createRoot(document.getElementById('root')!).render(); diff --git a/web/e2e/application-tabs.cjs b/web/e2e/application-tabs.cjs new file mode 100644 index 00000000..adc5eec7 --- /dev/null +++ b/web/e2e/application-tabs.cjs @@ -0,0 +1,38 @@ +const assert=require('node:assert/strict'); +const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright'); +(async()=>{const browser=await chromium.launch();try{ + for(const locale of ['zh-CN','en-US'])for(const dark of [false,true])for(const width of [1280,390]){ + const page=await browser.newPage({viewport:{width,height:900}}),errors=[]; + const tr=(a,b)=>locale==='zh-CN'?a:b; + page.on('pageerror',e=>errors.push(e.message)); + await page.addInitScript(({locale,dark})=>{localStorage.setItem('liaison-locale',locale);document.addEventListener('DOMContentLoaded',()=>{document.documentElement.classList.toggle('dark',dark);document.documentElement.classList.toggle('light',!dark);});},{locale,dark}); + const network={id:1,name:'Internal SSH',application_type:'ssh',ip:'127.0.0.1',port:22,edge_id:6,device:{name:'Mac mini'},created_at:'2026-09-26'}; + const agent={id:'a'.repeat(32),name:'Local Codex',kind:'codex',edge_id:6,installation_id:'b'.repeat(32),access_count:1}; + await page.route('**/api/v1/**',route=>{ + const path=new URL(route.request().url()).pathname;assert.equal(route.request().method(),'GET','No mutations while browsing'); + const data=path==='/api/v1/applications'?{applications:[network]}:path==='/api/v1/edges'?{edges:[{id:6,name:'Office',device:{name:'Mac mini'}}]}:path==='/api/v1/proxies'?{proxies:[]}:path==='/api/v1/agent-applications'?{items:[agent],total:1}:path==='/api/v1/edge-agents/connectors'?[{id:6,name:'Office',device:'Mac mini',online:true}]:{}; + return route.fulfill({json:{code:200,data}}); + }); + await page.goto((process.env.E2E_UI_URL||'http://127.0.0.1:5301')+'/e2e/application-tabs.html'); + await page.getByRole('cell',{name:'Internal SSH',exact:true}).waitFor();await page.getByRole('cell',{name:'Local Codex',exact:true}).waitFor(); + assert.equal(await page.locator('table').count(),1,'All applications is one table'); + assert(await page.getByRole('columnheader',{name:tr('应用类型','Application type'),exact:true}).isVisible()); + for(const name of ['Internal SSH','Local Codex'])assert.equal(await page.getByRole('cell',{name,exact:true}).locator('img,svg').count(),0,'No logo next to name'); + assert(await page.getByRole('cell',{name:'Codex',exact:true}).locator('img,svg').count()>0,'Logo in protocol column'); + assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)); + await page.screenshot({path:`/tmp/application-tabs-${locale}-${dark}-${width}.png`,fullPage:true}); + const search=page.getByPlaceholder(tr('输入应用名称','Application name'));await search.fill('Local Codex'); + await page.getByRole('cell',{name:'Internal SSH',exact:true}).waitFor({state:'hidden'});await page.getByRole('cell',{name:'Local Codex',exact:true}).waitFor(); + await page.getByRole('button',{name:tr('重置','Reset'),exact:true}).click();await page.getByRole('cell',{name:'Internal SSH',exact:true}).waitFor(); + await page.getByRole('button',{name:tr('新建应用','Create application'),exact:true}).click(); + await page.getByRole('combobox',{name:tr('应用分类','Application category')}).selectOption('agent'); + await page.getByRole('button',{name:tr('下一步','Next'),exact:true}).click();await page.getByRole('button',{name:tr('发现已安装的 Agent','Discover installed Agents')}).waitFor(); + await page.getByRole('button',{name:tr('取消','Cancel'),exact:true}).click(); + const category=async name=>{const button=page.getByRole('button',{name,exact:true});if(!await button.isVisible())await page.getByRole('button',{name:tr('更多协议','More protocols'),exact:true}).click();await page.getByRole('button',{name,exact:true}).click();}; + await category(tr('网络应用','Network applications')); + await page.getByRole('cell',{name:'Internal SSH',exact:true}).waitFor();assert.equal(await page.getByRole('cell',{name:'Local Codex',exact:true}).count(),0); + await category('Agent');await page.getByRole('cell',{name:'Local Codex',exact:true}).waitFor();assert.equal(await page.getByRole('cell',{name:'Internal SSH',exact:true}).count(),0); + assert.deepEqual(errors,[]);await page.close(); + } + console.log('PASS 8 application tabs, unified search, creation routing and protocol icon combinations'); +}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/web/e2e/application-tabs.html b/web/e2e/application-tabs.html new file mode 100644 index 00000000..2858debf --- /dev/null +++ b/web/e2e/application-tabs.html @@ -0,0 +1 @@ +
diff --git a/web/e2e/application-tabs.tsx b/web/e2e/application-tabs.tsx new file mode 100644 index 00000000..968f54b0 --- /dev/null +++ b/web/e2e/application-tabs.tsx @@ -0,0 +1,5 @@ +import {createRoot} from 'react-dom/client'; +import {BrowserRouter} from 'react-router-dom'; +import AppPage from '../src/pages/App'; +import '../src/styles/index.css'; +createRoot(document.getElementById('root')!).render(
); diff --git a/web/e2e/edge-agent.cjs b/web/e2e/edge-agent.cjs index 32f1e9b4..f8e2433e 100644 --- a/web/e2e/edge-agent.cjs +++ b/web/e2e/edge-agent.cjs @@ -15,11 +15,30 @@ const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright'); else {const query=new URL(req.url()).searchParams;const matched=entries.filter(e=>(!query.get('kind')||e.kind===query.get('kind'))&&e.name.toLowerCase().includes((query.get('name')||'').toLowerCase()));data=id?entries.find(e=>e.id===id):{items:matched,total:matched.length};} await route.fulfill({json:{data}}); }); + let applications=[]; + await page.route('**/api/v1/agent-applications**',async route=>{ + if(route.request().method()==='POST'){const app={...route.request().postDataJSON(),id:'f'.repeat(32),access_count:0};applications=[app];return route.fulfill({json:{data:app}});} + await route.fulfill({json:{data:{items:applications,total:applications.length}}}); + }); + const fileTransfers=new Map(),uploadedFiles=new Map();let fileSerial=0,failFile=false,slowFile=false; await page.route('**/api/v1/edge-agents**',async route=>{ const req=route.request();let data; if(req.method()==='GET')data=[{id:6,name:'Mac mini',device:'Mac-mini.local',online:!offline}]; else{ const body=req.postDataJSON(); + if(body.action.startsWith('file_')){ + const f=body.file;let reply={version:1,status:'ok'}; + if(failFile&&body.action==='file_begin')return route.fulfill({status:403,json:{message:'Forbidden'}}); + if(slowFile&&body.action==='file_write')await new Promise(resolve=>setTimeout(resolve,250)); + if(body.action==='file_write'&&!fileTransfers.has(f.transfer_id))return route.fulfill({json:{data:{version:1,status:'not_found'}}}); + if(body.action==='file_begin'){const id=String(++fileSerial).padStart(32,'0');fileTransfers.set(id,{name:f.name,size:f.size,data:Buffer.alloc(0)});reply.transfer_id=id;} + if(body.action==='file_write'){const t=fileTransfers.get(f.transfer_id);assert.equal(t.data.length,f.offset);t.data=Buffer.concat([t.data,Buffer.from(f.data,'base64')]);reply.file_offset=t.data.length;} + if(body.action==='file_commit'){const t=fileTransfers.get(f.transfer_id);assert.equal(t.data.length,t.size);const path='.liaison-attachments/'+body.session_id+'/'+f.transfer_id+'-'+t.name;uploadedFiles.set(path,t);reply.file={name:t.name,path,size:t.size};fileTransfers.delete(f.transfer_id);} + if(body.action==='file_cancel')fileTransfers.delete(f.transfer_id); + if(body.action==='file_list'){reply.directory='.';reply.files=[...uploadedFiles].map(([path,t])=>({name:t.name,path,size:t.size}));} + if(body.action==='file_read'){const t=uploadedFiles.get(f.path);if(!t)reply.status='invalid_request';else Object.assign(reply,{transfer_id:'1'.repeat(32),file_data:t.data.toString('base64'),file_offset:t.size,file_done:true});} + return route.fulfill({json:{data:reply}}); + } if(body.action==='watch')await new Promise(resolve=>setTimeout(resolve,150)); if(revoked&&['poll','watch'].includes(body.action))return route.fulfill({status:403,json:{message:'Forbidden'}}); if(body.session_id)snapshot=sessions.get(body.session_id); @@ -28,7 +47,7 @@ const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright'); else if(body.action==='directories'){const dir=body.directory||'/Users/local';data={version:1,status:dir==='/outside'?'invalid_request':'ok',directory:dir,parent_directory:'/Users/local',directory_roots:['/Users/local'],directories:dir.endsWith('/src')?[]:[{name:'src',path:'/Users/local/project/src'}]};} else if(body.action==='start'){starts++;data=snapshot={version:1,status:'ok',revision:1,session_id:String(starts).padStart(32,'b'),thread_id:'thread-'+starts,model:'configured-model',project:body.working_directory||body.project,started_at:'2026-09-21T03:00:00Z',skills_available:true,skills:[{id:'c'.repeat(32),name:'review-code',description:'Review the current project'}],running:false,closed:false,messages:[]};} else if(body.action==='resume'){data=snapshot={...snapshot,status:'ok',revision:snapshot.revision+1,running:false,closed:false,archived:false};} - else if(body.action==='transcript'){data=transcripts.get(body.session_id+':'+(Number(body.history_before)-1))||{version:1,status:'not_found'};} + else if(body.action==='transcript'){const pages=[...transcripts.values()].filter(p=>p.session_id===body.session_id&&(p.window||0)(b.window||0)-(a.window||0)).slice(0,Number(body.history_limit||1));data={version:1,status:'ok',session_id:body.session_id,history_pages:pages,history_before:pages.at(-1)?.window?String(pages.at(-1).window):''};} else if(body.action==='send'){if(snapshot.messages.length){transcripts.set(snapshot.session_id+':'+(snapshot.window||0),{...snapshot,window:snapshot.window||0,closed:true,running:false,archived:true});snapshot={...snapshot,window:(snapshot.window||0)+1};}sentSkill=body.skill_id||'';data=snapshot={...snapshot,messages:[{role:'user',text:body.text},{role:'assistant',text:'## Project\n\nUse `go test ./...` to verify.\n\n\n\n![remote](https://invalid.example/image.png)'}]}; } else if(body.action==='models')data={...snapshot,models_available:true,models:[{id:'configured-model',name:'Configured model'},{id:'native-other',name:'Native other'}]}; @@ -39,7 +58,10 @@ const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright'); assert.equal(body.input_id,'e'.repeat(32));assert.deepEqual(body.answers,[{question_id:'layout',answers:['Compact']},{question_id:'name',answers:['Workspace']}]); data=snapshot={...snapshot,input_requests:[],running:false,revision:snapshot.revision+1,activities:[ {id:'command',kind:'commandExecution',status:'completed',message_index:1,duration_ms:52,command:'go test ./...',directory:'/Users/local/project',output:'PASS\n',exit_code:0}, - {id:'file',kind:'fileChange',status:'completed',message_index:1,duration_ms:0,changes:[{path:'web/src/components/workspace.tsx',kind:'update',diff:'@@ -1,2 +1,2 @@\n-const title = "Old";\n+const title = "Workspace";\n export default title;'}]}, + {id:'file',kind:'fileChange',status:'completed',message_index:1,duration_ms:0,changes:[{path:'web/src/components/'+('long-directory-'.repeat(30))+'/workspace.tsx',kind:'update',diff:'@@ -1,2 +1,2 @@\n-const title = "Old";\n+const title = "'+('Workspace'.repeat(80))+'";\n export default title;'}]}, + {id:'plan',kind:'turnPlan',status:'completed',message_index:1,duration_ms:0,output:'Implementation and verification',plan:[{step:'Inspect the existing code',status:'completed'},{step:'Verify the changes',status:'completed'}]}, + {id:'search',kind:'webSearch',status:'completed',message_index:1,duration_ms:0,label:'SQLite WAL documentation'}, + {id:'mcp',kind:'mcpToolCall',status:'completed',message_index:1,duration_ms:0,label:'docs / search'}, {id:'input',kind:'userInput',status:'completed',message_index:1,duration_ms:0,output:'Layout?\n→ Compact\nName?\n→ Workspace'} ]}; } @@ -47,11 +69,13 @@ const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright'); else if(body.action==='delete'){sessions.delete(body.session_id);data={version:1,status:'ok'};} else if(body.action==='discard'){if(snapshot.running||snapshot.messages.length)data={version:1,status:'busy'};else{stops++;sessions.delete(body.session_id);data={version:1,status:'ok'};}} else if(body.action==='stop'){stops++;data=snapshot={...snapshot,closed:true};} + else if(body.action==='interrupt'){data=snapshot={...snapshot,running:false,input_requests:[],approvals:[],revision:snapshot.revision+1};sessions.set(snapshot.session_id,snapshot);} else data=snapshot; if(body.action==='send'){snapshot.revision++;snapshot.activities=[{id:'safe-tool-id',kind:'commandExecution',status:'completed',message_index:1,duration_ms:85}];} if(body.action==='send'&&body.text==='__approval_test__'){snapshot.title='project';snapshot.running=true;snapshot.approvals=[{id:'f'.repeat(32),command:'ls /private/tmp/liaison-approval-test',directory:'/private/tmp/liaison-approval-test',reason:'Inspect a temporary test directory'}];} if(body.action==='send'&&body.text==='__input_test__'){snapshot.title='project';snapshot.running=true;snapshot.input_requests=[{id:'e'.repeat(32),blocking:true,questions:[{id:'layout',header:'Layout',question:'Which layout should we use?',is_other:true,options:[{label:'Compact',description:'Keep the workspace focused.'},{label:'Detailed',description:'Show more information.'}]},{id:'name',header:'Name',question:'What should the workspace be called?'}]}];} - if(snapshot){snapshot.permissions_available=true;snapshot.permission_mode ||= 'read-only';} + if(body.action==='send'&&body.attachments?.length){snapshot.messages[0].attachments=body.attachments.map(path=>{const f=uploadedFiles.get(path);assert(f,'Send references uploaded files, never client-only paths');return {name:f.name,path,size:f.size};});} + if(snapshot){snapshot.permissions_available=true;snapshot.permission_mode ||= 'read-only';snapshot.files_available=true;snapshot.files_upload_available=true;} if(['permissions','approve','answer'].includes(body.action)&&snapshot)sessions.set(snapshot.session_id,snapshot); if(['start','resume','send','stop','model','rename'].includes(body.action)&&snapshot)sessions.set(snapshot.session_id,snapshot); } @@ -61,6 +85,7 @@ const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright'); await page.goto((process.env.E2E_UI_URL||'http://127.0.0.1:5298')+'/e2e/edge-agent.html'); const zh=locale==='zh-CN'; await page.getByRole('button',{name:zh?'新建访问':'Create access',exact:true}).click(); + await page.getByRole('combobox',{name:zh?'应用来源':'Application source'}).selectOption('new'); await page.getByRole('button',{name:zh?'发现已安装的 Agent':'Discover installed Agents'}).click(); await page.getByRole('button',{name:zh?'浏览':'Browse',exact:true}).click(); await page.getByRole('button',{name:'src',exact:true}).waitFor(); @@ -90,11 +115,178 @@ const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright'); await page.getByRole('button',{name:zh?'去访问':'Open',exact:true}).click(); await page.getByRole('button',{name:zh?'会话详情':'Session details',exact:true}).click(); await page.getByText('thread-1',{exact:true}).waitFor(); + await page.getByRole('dialog').getByText(zh?'历史保存在服务端,不自动过期。空闲时仅结束运行实例。':'History is stored on the server without automatic expiration. Idle instances may stop.',{exact:true}).waitFor(); await page.getByRole('dialog').getByRole('button',{name:zh?'关闭':'Close',exact:true}).last().click(); + assert.equal(await page.locator('.edge-agent-session-list footer').count(),0); + assert.equal(await page.locator('.edge-agent-project .edge-agent-codex-label').count(),0); + assert.equal(await page.locator('.edge-agent-breadcrumb .edge-agent-kind').innerText(),'Codex'); + assert.equal(await page.locator('.edge-agent-breadcrumb .edge-agent-kind svg').count(),1); + if(process.env.E2E_HISTORY_HEADER_ONLY==='1'){ + snapshot={...snapshot,closed:true,running:false,status:'session_closed',archived:true,messages:[{role:'user',text:'Earlier request'},{role:'assistant',text:'Saved history'}]};sessions.set(snapshot.session_id,snapshot); + await page.reload(); + const resume=page.getByRole('button',{name:zh?'恢复会话':'Resume conversation',exact:true});await resume.waitFor(); + assert.equal(await page.locator('.edge-agent-setup').count(),0); + assert.equal(await page.locator('.edge-agent-session-page > .liaison-notice').count(),0); + assert.equal(await page.locator('.edge-agent-workspace > header').count(),1); + await page.getByRole('button',{name:zh?'全页面展开':'Expand to full page',exact:true}).click(); + assert(await page.locator('.edge-agent-sessions-page').evaluate(el=>{const r=el.getBoundingClientRect();return r.x===0&&r.y===0&&r.width===innerWidth&&r.height===innerHeight;})); + const fullPageURL=page.url(); + assert.equal(new URL(fullPageURL).searchParams.get('view'),'full'); + await page.reload();await resume.waitFor(); + assert.equal(page.url(),fullPageURL,'Reload must preserve access, session and view'); + assert(await page.locator('.is-page-expanded').evaluate(el=>{const r=el.getBoundingClientRect();return r.x===0&&r.y===0&&r.width===innerWidth&&r.height===innerHeight;})); + await page.screenshot({path:`/tmp/agent-full-page-reload-${locale}-${dark}-${width}.png`,fullPage:true}); + await page.keyboard.press('Escape'); + assert.equal(await page.locator('.is-page-expanded').count(),0); + assert.equal(new URL(page.url()).searchParams.has('view'),false); + await page.reload();await resume.waitFor(); + assert.equal(await page.locator('.is-page-expanded').count(),0,'Escape must persist normal view'); + await page.getByRole('button',{name:zh?'全页面展开':'Expand to full page',exact:true}).click(); + await page.getByRole('button',{name:zh?'退出全页面':'Exit full page',exact:true}).click(); + await page.reload();await resume.waitFor(); + assert.equal(await page.locator('.is-page-expanded').count(),0,'Exit button must persist normal view'); + await page.screenshot({path:`/tmp/agent-history-header-${locale}-${dark}-${width}.png`,fullPage:true}); + offline=true;await page.reload();await page.getByText('Saved history',{exact:true}).waitFor(); + assert.equal(await resume.count(),0); + await page.getByTitle(zh?'连接器离线,暂时无法恢复。':'The connector is offline. Resume is temporarily unavailable.',{exact:true}).waitFor(); + assert.equal(starts,1);assert.deepEqual(errors,[]);await page.close();continue; + } + if(process.env.E2E_FILES_ONLY==='1'){ + const content=Buffer.from('Project specification\n'.repeat(10000)); + const uploader=page.locator('input[type=file]').first(); + await uploader.setInputFiles({name:'specification.txt',mimeType:'text/plain',buffer:content}); + await page.locator('.edge-agent-attachments').getByText('specification.txt',{exact:true}).waitFor(); + assert.deepEqual([...uploadedFiles.values()][0].data,content);assert.equal(fileTransfers.size,0); + const png=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jP1sAAAAASUVORK5CYII=','base64'); + await page.locator('input[type=file]').last().setInputFiles({name:'design.png',mimeType:'image/png',buffer:png}); + await page.getByRole('img',{name:'design.png'}).waitFor(); + await page.locator('.edge-agent-composer textarea').evaluate((el,png)=>{const data=new DataTransfer();data.items.add(new window.File([Uint8Array.from(atob(png),c=>c.charCodeAt(0))],'clipboard.png',{type:'image/png'}));el.dispatchEvent(new ClipboardEvent('paste',{clipboardData:data,bubbles:true,cancelable:true}));},png.toString('base64')); + await page.getByRole('img',{name:'clipboard.png'}).waitFor(); + await page.getByRole('button',{name:(zh?'移除附件':'Remove attachment')+' clipboard.png',exact:true}).click(); + await page.locator('.edge-agent-composer').evaluate(el=>{const data=new DataTransfer();data.items.add(new window.File(['dropped'],'drop.txt',{type:'text/plain'}));el.dispatchEvent(new DragEvent('drop',{dataTransfer:data,bubbles:true,cancelable:true}));}); + await page.locator('.edge-agent-attachments').getByText('drop.txt',{exact:true}).waitFor(); + await page.getByRole('button',{name:(zh?'移除附件':'Remove attachment')+' drop.txt',exact:true}).click(); + failFile=true;await uploader.setInputFiles({name:'denied.txt',mimeType:'text/plain',buffer:Buffer.from('denied')}); + await page.getByText(zh?'传输未完成。请检查连接器、访问权限、文件大小及路径后重试。':'Transfer failed. Check the connector, permissions, file size and path, then retry.',{exact:true}).waitFor(); + assert.equal(await page.locator('.edge-agent-attachments').getByText('denied.txt',{exact:true}).count(),0);failFile=false; + await page.getByRole('button',{name:zh?'关闭提示':'Dismiss',exact:true}).click(); + slowFile=true;await uploader.setInputFiles({name:'cancel.txt',mimeType:'text/plain',buffer:Buffer.alloc(1024*1024,1)}); + await page.locator('.edge-agent-transfer').getByRole('button',{name:zh?'取消':'Cancel',exact:true}).click(); + await page.locator('.edge-agent-transfer').waitFor({state:'hidden'});slowFile=false; + assert.equal(await page.locator('.edge-agent-attachments').getByText('cancel.txt',{exact:true}).count(),0); + assert(await page.getByRole('button',{name:zh?'发送':'Send',exact:true}).isEnabled(),'Attachment-only messages can be sent'); + await page.screenshot({path:`/tmp/agent-files-${locale}-${dark}-${width}.png`,fullPage:true}); + await page.getByRole('button',{name:zh?'添加与工具':'Add and tools',exact:true}).click(); + await page.getByRole('menuitem',{name:zh?'项目文件':'Project files',exact:true}).click(); + await page.getByRole('dialog').getByText('specification.txt',{exact:true}).waitFor(); + await page.screenshot({path:`/tmp/agent-project-files-${locale}-${dark}-${width}.png`,fullPage:true}); + const downloadEvent=page.waitForEvent('download'); + await page.getByRole('dialog').getByRole('button').filter({hasText:'specification.txt'}).click(); + const downloaded=await downloadEvent;assert.equal(downloaded.suggestedFilename(),'specification.txt'); + const stream=await downloaded.createReadStream();const chunks=[];for await(const part of stream)chunks.push(part);assert.deepEqual(Buffer.concat(chunks),content); + await page.getByRole('button',{name:(zh?'移除附件':'Remove attachment')+' design.png',exact:true}).click(); + assert.equal(await page.getByRole('img',{name:'design.png'}).count(),0); + await page.getByRole('button',{name:zh?'发送':'Send',exact:true}).click(); + await page.locator('.edge-agent-message-files').getByText('specification.txt',{exact:true}).waitFor(); + assert.equal(await page.locator('.edge-agent-attachments').count(),0,'Accepted send clears the composer attachments'); + assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1),'Attachments do not overflow'); + assert.deepEqual(errors,[]);await page.close();continue; + } + const root=page.locator('.edge-agent-sessions-page'),composer=page.getByRole('textbox',{name:zh?'消息':'Message',exact:true}); + await composer.fill('Keep this draft'); + const originalList=await page.locator('.edge-agent-session-list').isVisible(),originalStarts=starts; + await page.getByRole('button',{name:zh?'全页面展开':'Expand to full page',exact:true}).click(); + const bounds=await root.boundingBox();assert.equal(bounds.x,0);assert.equal(bounds.y,0);assert.equal(bounds.width,width);assert.equal(bounds.height,900); + assert.equal(await page.locator('.edge-agent-session-list').isVisible(),originalList); + await page.getByRole('button',{name:originalList?(zh?'收起会话列表':'Hide conversations'):(zh?'显示会话列表':'Show conversations'),exact:true}).click(); + assert.equal(await page.locator('.edge-agent-session-list').isVisible(),!originalList); + await page.getByRole('button',{name:!originalList?(zh?'收起会话列表':'Hide conversations'):(zh?'显示会话列表':'Show conversations'),exact:true}).click(); + await page.getByRole('button',{name:zh?'会话详情':'Session details',exact:true}).click(); + await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape'); + await page.getByRole('dialog').waitFor({state:'hidden'}); + assert(await root.evaluate(el=>el.classList.contains('is-page-expanded')),'Closing a dialog must not exit full page'); + await page.screenshot({path:`/tmp/agent-full-page-${locale}-${dark}-${width}.png`,fullPage:true}); + await page.keyboard.press('Escape'); + assert.equal(await root.evaluate(el=>el.classList.contains('is-page-expanded')),false); + assert.equal(await composer.inputValue(),'Keep this draft');assert.equal(starts,originalStarts,'View changes never restart the session'); + assert.equal(await page.locator('.edge-agent-session-list').isVisible(),originalList); + await composer.fill(''); + await page.getByRole('button',{name:zh?'全页面展开':'Expand to full page',exact:true}).click(); + await page.getByRole('button',{name:zh?'退出全页面':'Exit full page',exact:true}).click(); + assert.equal(await root.evaluate(el=>el.classList.contains('is-page-expanded')),false); + assert.equal(await page.locator('.edge-agent-footnote').count(),0); + assert.equal(await page.locator('.edge-agent-composer .edge-agent-permission').count(),1); + assert.equal(await page.locator('.edge-agent-composer .edge-agent-keyhint').count(),1); + assert.equal(await page.locator('.edge-agent-keyhint').isVisible(),width>640); + if(process.env.E2E_FOOTNOTE_ONLY==='1'){ + const message=page.getByRole('textbox',{name:zh?'消息':'Message',exact:true}); + const slash=page.locator('.edge-agent-slash'); + const tools=page.getByRole('button',{name:zh?'添加与工具':'Add and tools',exact:true}); + await message.fill('/');await slash.waitFor(); + await page.locator('.edge-agent-project strong').click(); + await slash.waitFor({state:'hidden'}); + assert.equal(await message.inputValue(),'/','Outside click preserves the draft'); + await message.fill('/review');await slash.waitFor(); + await tools.focus();await slash.waitFor({state:'hidden'}); + await message.fill('/');await slash.waitFor(); + await message.press('Escape');await slash.waitFor({state:'hidden'}); + await message.fill('Keep this draft'); + await tools.click();await page.keyboard.press('Escape'); + assert(await tools.evaluate(el=>document.activeElement===el),'Tools restore focus on Escape'); + await tools.click();await page.getByRole('menuitem',{name:zh?'命令与技能':'Commands and skills'}).click(); + await slash.waitFor(); + const popup=await slash.boundingBox(); + assert(popup.x>=0&&popup.y>=0&&popup.x+popup.width<=width+1,'Skill menu fits the viewport'); + await page.screenshot({path:`/tmp/agent-menu-${locale}-${dark}-${width}.png`,fullPage:true}); + await page.getByRole('option',{name:'review-code Review the current project'}).click(); + await slash.waitFor({state:'hidden'}); + assert.equal(await message.inputValue(),'Keep this draft'); + assert(await message.evaluate(el=>document.activeElement===el),'Skill selection restores composer focus'); + await page.getByRole('button',{name:zh?'移除技能':'Remove skill'}).click(); + await message.fill('/review');await page.getByRole('option',{name:'review-code Review the current project'}).waitFor();await message.press('Enter');await slash.waitFor({state:'hidden'}); + await page.getByRole('button',{name:zh?'移除技能':'Remove skill'}).click(); + await message.fill('/');await slash.waitFor(); + await page.getByRole('button',{name:zh?'切换模型':'Switch model',exact:true}).click(); + await slash.waitFor({state:'hidden'});await page.getByRole('dialog').waitFor(); + await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'}); + await message.fill(''); + const separator=page.getByRole('separator',{name:zh?'调整会话列表宽度':'Resize session list'}); + if(width>=1000){ + // Shared CSS can arrive after page CSS in the production lazy-loaded chunks. + await page.addStyleTag({content:'.agent-workspace-divider { left:-3px; }'}); + const sidebar=await page.locator('.edge-agent-session-list').boundingBox(),main=await page.locator('.edge-agent-session-main').boundingBox(); + assert(Math.abs(sidebar.x+sidebar.width-main.x)<1,'Sidebar and conversation share a panel without a gap'); + const handle=await separator.boundingBox(); + assert(Math.abs(handle.x+handle.width/2-main.x)<2,'Drag hit area stays on the visible divider regardless of stylesheet order'); + assert.equal(await page.locator('.edge-agent-workspace').evaluate(el=>getComputedStyle(el).borderTopWidth),'0px'); + await separator.waitFor();const box=await separator.boundingBox(); + await page.mouse.move(box.x+box.width/2,box.y+80);await page.mouse.down();await page.mouse.move(box.x+box.width/2+90,box.y+80,{steps:8});await page.mouse.up(); + await page.mouse.move(20,20);await page.waitForTimeout(200); + assert.equal(await separator.evaluate(el=>getComputedStyle(el,'::after').backgroundColor),'rgba(0, 0, 0, 0)','Pointer drag must not leave a highlighted separator'); + assert.equal(Math.round((await page.locator('.edge-agent-session-list').boundingBox()).width),320); + await page.reload();await separator.waitFor(); + assert.equal(Math.round((await page.locator('.edge-agent-session-list').boundingBox()).width),320,'Sidebar width survives reload'); + await separator.focus();await page.keyboard.press('ArrowLeft');assert.equal(await separator.getAttribute('aria-valuenow'),'310'); + await page.keyboard.press('End');assert.equal(await separator.getAttribute('aria-valuenow'),'420'); + await page.keyboard.press('Home');assert.equal(await separator.getAttribute('aria-valuenow'),'200'); + await separator.dblclick();assert.equal(await separator.getAttribute('aria-valuenow'),'230'); + await page.keyboard.press('ArrowRight'); + await page.getByRole('textbox',{name:zh?'消息':'Message',exact:true}).focus(); + }else{ + await page.getByRole('button',{name:zh?'显示会话列表':'Show conversations',exact:true}).click(); + assert.equal(await separator.isVisible(),false,'Mobile keeps drawer without drag handle'); + await page.getByRole('button',{name:zh?'收起会话列表':'Hide conversations',exact:true}).click(); + } + await page.mouse.move(10,10);await page.waitForTimeout(200); + assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1),'Resize does not cause page overflow'); + await page.screenshot({path:`/tmp/agent-footnote-${locale}-${dark}-${width}.png`,fullPage:true}); + assert.deepEqual(errors,[]);await page.close();continue; + } await page.getByRole('textbox',{name:zh?'消息':'Message',exact:true}).fill('project'); await page.getByRole('button',{name:zh?'发送':'Send',exact:true}).click(); - await page.getByRole('heading',{name:'Project',exact:true}).waitFor(); - await page.getByRole('button',{name:zh?'只读':'Read-only',exact:true}).click(); + await page.getByRole('heading',{name:'Project',exact:true}).last().waitFor(); + assert.equal(await page.locator('.agent-message > .edge-agent-codex-label svg').count(),0,'Message authors are text only'); + await page.getByRole('button',{name:zh?'按需授权':'Approval on request',exact:true}).click(); await page.getByRole('menuitem',{name:zh?'允许工作区写入':'Allow workspace writes',exact:true}).click(); await page.getByRole('button',{name:zh?'工作区写入':'Workspace write',exact:true}).waitFor(); await page.getByRole('textbox',{name:zh?'消息':'Message',exact:true}).fill('__approval_test__'); @@ -114,8 +306,12 @@ const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright'); await page.screenshot({path:`/tmp/agent-input-${locale}-${dark}-${width}.png`,fullPage:true}); await inputForm.getByRole('button',{name:zh?'提交回答':'Submit answers'}).click(); await inputForm.waitFor({state:'detached'}); - await page.locator('.edge-agent-activity > summary').click(); + await page.locator('.edge-agent-activity > summary').last().click(); + const commandSummary=page.locator('.edge-agent-activity li.has-detail > details > summary').filter({hasText:zh?'执行命令':'Run command'}); + assert.equal(await commandSummary.locator('.edge-agent-disclosure').evaluate(el=>getComputedStyle(el).transform),'none','Closed command points right'); + assert.equal(await commandSummary.locator('.lucide-check').evaluate(el=>getComputedStyle(el).transform),'none','Status check never rotates with parent'); await page.locator('.edge-agent-activity li.has-detail > details > summary').filter({hasText:zh?'执行命令':'Run command'}).click(); + assert.equal(await commandSummary.locator('.edge-agent-disclosure').evaluate(el=>getComputedStyle(el).transform),'matrix(0, 1, -1, 0, 0, 0)','Open command points down'); await page.getByLabel(zh?'输出':'Output',{exact:true}).filter({hasText:'PASS'}).waitFor(); await page.locator('.edge-agent-activity li.has-detail > details > summary').filter({hasText:zh?'文件操作':'File operation'}).click(); await page.locator('.edge-agent-file-change > summary').click(); @@ -123,16 +319,52 @@ const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright'); await page.getByRole('region',{name:zh?'代码差异':'Code diff'}).scrollIntoViewIfNeeded(); assert.equal(await page.locator('.edge-agent-diff-line.added').count(),1); assert.equal(await page.locator('.edge-agent-diff-line.removed').count(),1); + await page.getByRole('region',{name:zh?'任务计划':'Task plan'}).waitFor(); + await page.getByText('SQLite WAL documentation',{exact:true}).waitFor(); + await page.getByText('docs / search',{exact:true}).waitFor(); + assert(await page.locator('.edge-agent-messages').evaluate(el=>el.scrollWidth<=el.clientWidth+1),'Only code/diff containers may scroll horizontally, never the conversation'); + if(process.env.E2E_ACTIVITY_ONLY==='1'){ + const publish=patch=>{snapshot={...snapshot,...patch,revision:snapshot.revision+1};sessions.set(snapshot.session_id,snapshot);}; + publish({window:200,messages:[{role:'assistant',text:'Executing checks'}],activities:[{id:'live-command',kind:'commandExecution',status:'running',message_index:0,duration_ms:0,command:'go test ./...',output:'Running tests…'}],running:true}); + const activity=page.locator('.edge-agent-activity').last(),detail=activity.locator('li.has-detail > details').first(); + await page.locator('.edge-agent-command-output').filter({hasText:'Running tests…'}).waitFor(); + assert(await activity.evaluate(el=>el.open),'Live activity opens');assert(await detail.evaluate(el=>el.open),'Live command output opens'); + publish({running:false,activities:snapshot.activities.map(a=>({...a,status:'completed',output:'PASS',exit_code:0}))}); + await page.waitForFunction(()=>Array.from(document.querySelectorAll('.edge-agent-activity')).at(-1)?.open===false); + assert(!(await detail.evaluate(el=>el.open)),'Completed command collapses'); + await activity.locator(':scope > summary').click();await detail.locator(':scope > summary').click(); + publish({running:true,activities:snapshot.activities.map(a=>({...a,status:'running'}))}); + await page.waitForTimeout(350);publish({running:false,activities:snapshot.activities.map(a=>({...a,status:'failed'}))}); + await detail.getByText(zh?'失败':'Failed',{exact:true}).waitFor(); + assert(await activity.evaluate(el=>el.open),'Manual expansion survives completion');assert(await detail.evaluate(el=>el.open),'Manual detail expansion survives completion'); + for(const decision of ['accept','decline']){ + publish({running:true,approvals:[{id:'f'.repeat(32),kind:'fileChange',directory:'/project',reason:'Review this patch',command:'',changes:[{path:'notes.txt',kind:'update',diff:'@@ -1 +1 @@\n-old\n+new'}]}]}); + const approval=page.locator('.edge-agent-approval');await approval.getByText(zh?'允许这些文件修改?':'Allow these file changes?',{exact:true}).waitFor(); + await approval.locator('.edge-agent-diff-line.added').waitFor(); + assert(!(await approval.innerText()).includes('越出当前沙箱'),'File approval uses file-specific copy'); + await page.screenshot({path:`/tmp/agent-file-approval-${locale}-${dark}-${width}.png`,fullPage:true}); + await approval.getByRole('button',{name:decision==='accept'?(zh?'允许一次':'Allow once'):(zh?'拒绝':'Decline'),exact:true}).click();await approval.waitFor({state:'detached'}); + } + publish({running:true,input_requests:[{id:'e'.repeat(32),blocking:true,questions:[{id:'choice',question:'Choose or enter an answer',is_other:true,is_secret:true,options:[{label:'Default',description:'Use the default'}]}]}]}); + await inputForm.waitFor();assert(await inputForm.getByRole('button',{name:zh?'提交回答':'Submit answers'}).isDisabled()); + await inputForm.getByRole('radio',{name:zh?'其他回答':'Other answer',exact:true}).check(); + const secret=inputForm.locator('input[type=password]');await secret.fill('isolated-test-value');assert(!(await inputForm.getByRole('button',{name:zh?'提交回答':'Submit answers'}).isDisabled())); + await inputForm.getByRole('button',{name:zh?'停止本轮':'Stop turn',exact:true}).click();await inputForm.waitFor({state:'detached'}); + publish({running:false,activities:[{id:'cancelled',kind:'userInput',status:'cancelled',message_index:0,duration_ms:0},{id:'ended',kind:'reasoning',status:'ended',message_index:0,duration_ms:0},{id:'failed',kind:'commandExecution',status:'failed',message_index:0,duration_ms:0,command:'exit 1',output:'Test failure',exit_code:1}]}); + await activity.getByText(zh?'已取消':'Cancelled',{exact:true}).waitFor(); + assert.equal(await activity.locator('.edge-agent-spinner').count(),0,'Ended history has no spinning indicators'); + assert(await page.locator('.edge-agent-messages').evaluate(el=>el.scrollWidth<=el.clientWidth+1)); + await page.screenshot({path:`/tmp/agent-disclosure-${locale}-${dark}-${width}.png`,fullPage:true}); + assert.deepEqual(errors,[]);await page.close();continue; + } await page.screenshot({path:`/tmp/agent-details-${locale}-${dark}-${width}.png`,fullPage:true}); - await page.getByRole('button',{name:zh?'更早一轮':'Previous round',exact:true}).click(); - await page.getByRole('button',{name:zh?'返回最新':'Back to latest',exact:true}).waitFor(); - assert((await page.locator('.agent-message.is-user').innerText()).includes('approval_test')); - await page.getByRole('button',{name:zh?'更早一轮':'Previous round',exact:true}).click(); - await page.waitForFunction(()=>document.querySelector('.agent-message.is-user')?.textContent?.endsWith('project')); - assert(await page.getByRole('button',{name:zh?'更早一轮':'Previous round',exact:true}).isDisabled()); + await page.waitForFunction(()=>document.querySelectorAll('.agent-message.is-user').length===3); + const roundUsers=await page.locator('.agent-message.is-user').allTextContents(); + assert(roundUsers[0].endsWith('project')); + assert(roundUsers[1].includes('approval_test')); + assert(roundUsers[2].includes('input_test')); + assert.equal(await page.getByRole('button',{name:zh?'加载更早记录':'Load earlier messages',exact:true}).count(),0); await page.screenshot({path:`/tmp/agent-round-history-${locale}-${dark}-${width}.png`,fullPage:true}); - await page.getByRole('button',{name:zh?'返回最新':'Back to latest',exact:true}).click(); - await page.waitForFunction(()=>document.querySelector('.agent-message.is-user')?.textContent?.endsWith('input_test')); await page.getByRole('button',{name:zh?'选择工作目录':'Choose working directory',exact:true}).click(); await page.getByRole('button',{name:'src',exact:true}).click(); await page.getByText(zh?'此目录下没有可显示的子目录。':'No visible subfolders in this directory.').waitFor(); @@ -184,7 +416,7 @@ const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright'); await page.getByRole('button',{name:zh?'移除技能':'Remove skill'}).waitFor(); await page.getByRole('textbox',{name:zh?'消息':'Message',exact:true}).fill('Explain this project'); await page.getByRole('button',{name:zh?'发送':'Send',exact:true}).click(); - await page.getByRole('heading',{name:'Project',exact:true}).waitFor(); + await page.getByRole('heading',{name:'Project',exact:true}).last().waitFor(); assert(await input.evaluate(el=>document.activeElement===el),'Composer retains focus after send'); assert.equal(sentSkill,'c'.repeat(32)); assert.equal(starts,2); @@ -195,7 +427,7 @@ const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright'); await page.screenshot({path:`/tmp/liaison-edge-agent-${locale}-${dark?'dark':'light'}-${width}.png`,fullPage:true}); assert.equal(stops,0,'Directory switch must not stop the original session'); await page.reload(); - await page.getByRole('heading',{name:'Project',exact:true}).waitFor(); + await page.getByRole('heading',{name:'Project',exact:true}).last().waitFor(); assert.equal(starts,2,'Reload must reconnect without launching a new process'); const toggle=page.getByRole('button',{name:zh?/^(展开|收起)会话$/:/^(Show|Hide) sessions$/}); if(await toggle.getAttribute('aria-expanded')==='false')await toggle.click(); @@ -215,7 +447,7 @@ const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright'); assert.equal(starts,2); if(await toggle.getAttribute('aria-expanded')==='false')await toggle.click(); await page.locator('.edge-agent-session-item').filter({hasText:'Explain this project'}).click(); - await page.getByRole('heading',{name:'Project',exact:true}).waitFor(); + await page.getByRole('heading',{name:'Project',exact:true}).last().waitFor(); await page.screenshot({path:`/tmp/agent-sessions-${locale}-${dark}-${width}.png`,fullPage:true}); assert.equal(stops,0); if(await toggle.getAttribute('aria-expanded')==='false')await toggle.click(); @@ -255,19 +487,19 @@ const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright'); if(await toggle.getAttribute('aria-expanded')==='false')await toggle.click(); await page.getByRole('button',{name:zh?'刷新会话':'Refresh sessions'}).click(); await page.locator('.edge-agent-session-item').filter({hasText:'project'}).click(); - await page.getByRole('heading',{name:'Project',exact:true}).waitFor(); + await page.getByRole('heading',{name:'Project',exact:true}).last().waitFor(); snapshot={...snapshot,closed:true,running:false,status:'session_closed'};sessions.set(snapshot.session_id,snapshot); offline=true; await page.goto((process.env.E2E_UI_URL||'http://127.0.0.1:5298')+'/e2e/edge-agent.html'); await page.getByRole('button',{name:zh?'去访问':'Open',exact:true}).click(); - await page.getByText(zh?'正在查看服务端保存的历史。若原设备上的 Codex 会话仍在,可以恢复续聊。':'Viewing server history. You can resume if the native Codex conversation is still available on the original device.',{exact:true}).waitFor(); - await page.getByRole('heading',{name:'Project',exact:true}).waitFor(); + await page.getByTitle(zh?'连接器离线,暂时无法恢复。':'The connector is offline. Resume is temporarily unavailable.',{exact:true}).waitFor(); + await page.getByRole('heading',{name:'Project',exact:true}).last().waitFor(); assert(await page.getByRole('button',{name:zh?'发送':'Send',exact:true}).isDisabled()); assert.equal(starts,4,'Offline history must not launch a new process'); assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)); await page.screenshot({path:`/tmp/agent-history-${locale}-${dark}-${width}.png`,fullPage:true}); offline=false;await page.reload(); - await page.getByRole('heading',{name:'Project',exact:true}).waitFor(); + await page.getByRole('heading',{name:'Project',exact:true}).last().waitFor(); await page.getByRole('button',{name:zh?'恢复会话':'Resume conversation',exact:true}).click(); await page.getByRole('textbox',{name:zh?'消息':'Message',exact:true}).waitFor(); revoked=true; @@ -275,6 +507,6 @@ const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright'); assert(await page.getByRole('button',{name:zh?'发送':'Send',exact:true}).isDisabled()); assert.deepEqual(errors,[]);await page.close(); } - console.log('PASS 8 locale/theme/viewport combinations; discovery, chat, safe Markdown, permission revocation, overflow'); + console.log(process.env.E2E_FILES_ONLY==='1'?'PASS 8 locale/theme/viewport combinations; uploads, native-image attachment UI, paste, drag/drop, cancellation, permission failures, byte-exact download, attachment-only send':process.env.E2E_FOOTNOTE_ONLY==='1'?'PASS 8 locale/theme/viewport combinations; history info in details, clean footnote, responsive keyboard hint':'PASS 8 locale/theme/viewport combinations; discovery, chat, safe Markdown, permission revocation, overflow'); }finally{await browser.close();} })().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/web/e2e/llm-create.cjs b/web/e2e/llm-create.cjs index 9f7c026f..daa5fd1f 100644 --- a/web/e2e/llm-create.cjs +++ b/web/e2e/llm-create.cjs @@ -28,7 +28,7 @@ const assert=require('node:assert/strict'); const fill=async()=>{await newMode().click();await page.getByLabel(zh?'应用地址':'Application address',{exact:false}).fill('http://127.0.0.1:8000/v1');}; const probe=()=>page.getByRole('button',{name:zh?'检测连接并获取模型':'Check connection & fetch models'}).click(); await visit();await open(); - assert(await page.getByRole('dialog').getByLabel(zh?'访问协议':'Protocol',{exact:true}).isVisible()); + assert(await page.getByRole('dialog').getByLabel(zh?'访问类型':'Access type',{exact:true}).isVisible()); assert(await page.getByRole('dialog').getByLabel(zh?'访问名称':'Access name',{exact:true}).isVisible()); assert(await page.getByRole('dialog').getByRole('button',{name:zh?'创建访问':'Create access',exact:true}).isDisabled()); await page.screenshot({path:`/tmp/llm-fields-${locale}-${theme}.png`}); diff --git a/web/e2e/mermaid.cjs b/web/e2e/mermaid.cjs new file mode 100644 index 00000000..a928d925 --- /dev/null +++ b/web/e2e/mermaid.cjs @@ -0,0 +1,64 @@ +const assert=require('node:assert/strict'); +const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright'); +(async()=>{ + const browser=await chromium.launch(process.env.CHROME_EXECUTABLE?{executablePath:process.env.CHROME_EXECUTABLE}:undefined); + try{ + for(const locale of ['zh-CN','en-US'])for(const dark of [false,true])for(const width of [1280,390]){ + const page=await browser.newPage({viewport:{width,height:900}}),errors=[],remote=[]; + page.on('pageerror',e=>errors.push(e.message));page.on('request',r=>{if(r.url().includes('invalid.example'))remote.push(r.url());}); + await page.addInitScript(({locale,dark})=>{if(window!==top)return;localStorage.setItem('liaison-locale',locale);document.addEventListener('DOMContentLoaded',()=>document.documentElement.className=dark?'dark':'light');},{locale,dark}); + await page.goto((process.env.E2E_UI_URL||'http://127.0.0.1:5301')+'/e2e/mermaid.html'); + const zh=locale==='zh-CN',frame=()=>page.frameLocator('iframe'),ready=async()=>{await frame().locator('svg').waitFor();await page.waitForFunction(()=>document.querySelector('iframe')?.style.visibility==='visible');}; + await ready();assert.equal(await page.locator('iframe').getAttribute('sandbox'),'allow-scripts'); + assert.equal(await frame().locator('html').evaluate(el=>getComputedStyle(el).backgroundColor),dark?'rgb(11, 11, 17)':'rgb(244, 245, 248)'); + assert.equal(await frame().locator('body').evaluate(()=>{try{return !!parent.document}catch{return false}}),false,'Renderer cannot access console DOM'); + assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)); + await page.screenshot({path:`/tmp/mermaid-${locale}-${dark}-${width}.png`,fullPage:true}); + await frame().locator('body').evaluate(()=>window.__diagramSentinel='stable'); + await page.waitForTimeout(1000); + await page.getByRole('button',{name:'Append text',exact:true}).click(); + await page.evaluate(()=>document.documentElement.style.setProperty('--unrelated','1')); + await page.waitForTimeout(700); + assert.equal(await frame().locator('body').evaluate(()=>window.__diagramSentinel),'stable','Polling, trailing streamed text and unrelated style mutations must preserve the iframe'); + await page.getByRole('button',{name:zh?'放大图表':'Expand diagram',exact:true}).click(); + const dialog=page.getByRole('dialog'),large=dialog.frameLocator('iframe'); + await large.locator('svg').waitFor(); + assert(await large.locator('svg').evaluate(el=>{const r=el.getBoundingClientRect();return r.width<=innerWidth&&r.height<=innerHeight;}),'Fit window shows the whole diagram'); + await dialog.getByRole('combobox',{name:zh?'缩放比例':'Zoom level'}).selectOption('200'); + await page.waitForTimeout(150); + assert(await large.locator('svg').evaluate(el=>el.getBoundingClientRect().width>innerWidth),'200% zoom expands the diagram inside the viewer'); + const bounds=await dialog.locator('iframe').boundingBox(); + await page.mouse.move(bounds.x+150,bounds.y+80);await page.mouse.down();await page.mouse.move(bounds.x+70,bounds.y+50,{steps:8});await page.mouse.up(); + assert(await large.locator('body').evaluate(()=>scrollX>0),'Dragging pans the enlarged diagram'); + await dialog.getByRole('button',{name:zh?'适应窗口':'Fit window',exact:true}).click(); + await page.waitForTimeout(150);assert(await large.locator('body').evaluate(()=>scrollX===0&&scrollY===0)); + assert(await dialog.getByRole('combobox',{name:zh?'缩放比例':'Zoom level'}).evaluate(el=>parseFloat(getComputedStyle(el).lineHeight)<=el.clientHeight),'Zoom text fits its control'); + await large.locator('body').evaluate(()=>{window.__fitCount=0;window.addEventListener('message',e=>{if(e.data?.type==='liaison-mermaid-scale')window.__fitCount++});}); + await dialog.getByRole('button',{name:zh?'适应窗口':'Fit window',exact:true}).click(); + await page.waitForTimeout(150); + assert.equal(await large.locator('body').evaluate(()=>window.__fitCount),1,'Fit resets even when already selected'); + await page.screenshot({path:`/tmp/mermaid-viewer-${locale}-${dark}-${width}.png`,fullPage:true}); + const panel=dialog.locator('.liaison-modal'),diagramBox=await panel.boundingBox(); + await dialog.getByRole('button',{name:zh?'源码':'Source',exact:true}).click(); + const sourceBox=await panel.boundingBox(); + assert(Math.abs(diagramBox.height-sourceBox.height)<1&&Math.abs(diagramBox.y-sourceBox.y)<1,'Source tab preserves modal height and position'); + assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)); + await page.screenshot({path:`/tmp/mermaid-source-${locale}-${dark}-${width}.png`,fullPage:true}); + await dialog.getByRole('button',{name:zh?'图形':'Diagram',exact:true}).click(); + await large.locator('body').click();await page.keyboard.press('Escape');await dialog.waitFor({state:'hidden'}); + assert.equal(await frame().locator('body').evaluate(()=>window.__diagramSentinel),'stable','Opening and closing viewer must not reload inline diagram'); + await page.getByRole('button',{name:zh?'源码':'Source',exact:true}).click();assert((await page.locator('pre').innerText()).includes('flowchart LR')); + await page.getByRole('button',{name:zh?'图形':'Diagram',exact:true}).click();await ready(); + await page.getByRole('textbox',{name:'Diagram source'}).fill('sequenceDiagram\n participant A as Client\n participant B as Codex\n A->>B: Hello\n B-->>A: Answer');await ready();await frame().getByText('Hello',{exact:true}).waitFor(); + await page.getByRole('textbox',{name:'Diagram source'}).fill('flowchart LR\n A[""] --> B[Safe]\n click B "https://invalid.example/click"'); + await ready();assert.deepEqual(remote,[]);assert.equal(await frame().locator('a[href], image[href], img[src]').count(),0); + await page.getByRole('textbox',{name:'Diagram source'}).fill('not a diagram');await page.getByRole('status').filter({hasText:zh?'暂时无法':'Unable to render'}).waitFor(); + await page.getByRole('textbox',{name:'Diagram source'}).fill('flowchart TB\n A --> B');await ready(); + await page.evaluate(()=>document.documentElement.className=document.documentElement.classList.contains('dark')?'light':'dark'); + await page.waitForTimeout(500);await ready(); + await page.getByRole('textbox',{name:'Diagram source'}).fill('%%{init: {"securityLevel":"loose"}}%%\nflowchart LR\n A --> B');await page.getByRole('status').filter({hasText:zh?'暂时无法':'Unable to render'}).waitFor(); + assert.deepEqual(errors,[]);await page.close(); + } + console.log('PASS Mermaid: flowchart, sequence, source toggle, streaming recovery, theme, sandbox, remote image/link blocking, configuration rejection, 8 UI combinations'); + }finally{await browser.close();} +})().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/web/e2e/mermaid.html b/web/e2e/mermaid.html new file mode 100644 index 00000000..81cddd98 --- /dev/null +++ b/web/e2e/mermaid.html @@ -0,0 +1 @@ +Mermaid fixture
diff --git a/web/e2e/mermaid.tsx b/web/e2e/mermaid.tsx new file mode 100644 index 00000000..e9f4e81e --- /dev/null +++ b/web/e2e/mermaid.tsx @@ -0,0 +1,12 @@ +import {useEffect,useState} from 'react'; +import {createRoot} from 'react-dom/client'; +import {MessageContent} from '../src/components/AgentWorkspace/MessageContent'; +import '../src/styles/index.css'; +import '../src/components/AgentWorkspace/index.less'; +function Fixture(){ + const [tick,setTick]=useState(0),[tail,setTail]=useState(''); + useEffect(()=>{const timer=setInterval(()=>setTick(n=>n+1),200);return()=>clearInterval(timer);},[]); + const [code,setCode]=useState('flowchart LR\n A["客户端 Client"] --> B["Liaison"]\n B --> C["连接器 Connector"]\n C --> D["Codex app-server"]'); + return
Poll {tick}