From 32b37faa6d73af13bf6181372b8d3b1956a59c3a Mon Sep 17 00:00:00 2001 From: sam2tom Date: Thu, 17 Sep 2026 21:12:49 +0800 Subject: [PATCH] feat(web): add self-hosted Session connection flow --- .env.example | 16 + README.md | 48 ++- README.zh-CN.md | 27 +- apps/web/e2e/agents-lifecycle.spec.ts | 279 +++++++++++--- apps/web/e2e/fixture-core.mjs | 67 +++- apps/web/src/App.tsx | 68 +++- .../src/components/ConnectionModal.test.tsx | 2 +- apps/web/src/components/ConnectionModal.tsx | 2 +- .../features/CoreCollectionStates.test.tsx | 8 +- .../src/features/agents/AgentSetupView.tsx | 2 +- apps/web/src/features/agents/AgentsView.tsx | 4 +- .../src/features/sessions/SessionsView.tsx | 230 ++++++----- .../create/SessionStartDialog.test.tsx | 103 +++++ .../sessions/create/SessionStartDialog.tsx | 220 +++++++++++ .../create/session-create-attempt.test.ts | 47 +++ .../sessions/create/session-create-attempt.ts | 29 ++ .../create/session-environment.test.ts | 59 +++ .../sessions/create/session-environment.ts | 41 ++ .../environment/EnvironmentDialog.test.tsx | 66 ++++ .../environment/EnvironmentDialog.tsx | 47 +++ .../environment/EnvironmentPanel.test.tsx | 132 ++++++- .../sessions/environment/EnvironmentPanel.tsx | 219 ++++++++++- .../environment/environment-launcher.test.ts | 78 ++++ .../environment/environment-launcher.ts | 163 ++++++++ .../features/sessions/pending-message.test.ts | 51 +++ .../src/features/sessions/pending-message.ts | 42 ++ apps/web/src/lib/docker-guide-config.test.ts | 48 +++ apps/web/src/lib/docker-guide-config.ts | 58 +++ apps/web/src/style.css | 364 +++++++++++++++++- apps/web/src/vite-env.d.ts | 8 + apps/web/vite.config.ts | 5 + docs/architecture.md | 80 +++- docs/core-connection.md | 97 ++++- docs/protocol-coverage.md | 79 ++-- playwright.config.ts | 2 +- 35 files changed, 2564 insertions(+), 227 deletions(-) create mode 100644 apps/web/src/features/sessions/create/SessionStartDialog.test.tsx create mode 100644 apps/web/src/features/sessions/create/SessionStartDialog.tsx create mode 100644 apps/web/src/features/sessions/create/session-create-attempt.test.ts create mode 100644 apps/web/src/features/sessions/create/session-create-attempt.ts create mode 100644 apps/web/src/features/sessions/create/session-environment.test.ts create mode 100644 apps/web/src/features/sessions/create/session-environment.ts create mode 100644 apps/web/src/features/sessions/environment/EnvironmentDialog.test.tsx create mode 100644 apps/web/src/features/sessions/environment/EnvironmentDialog.tsx create mode 100644 apps/web/src/features/sessions/environment/environment-launcher.test.ts create mode 100644 apps/web/src/features/sessions/environment/environment-launcher.ts create mode 100644 apps/web/src/features/sessions/pending-message.test.ts create mode 100644 apps/web/src/features/sessions/pending-message.ts create mode 100644 apps/web/src/lib/docker-guide-config.test.ts create mode 100644 apps/web/src/lib/docker-guide-config.ts diff --git a/.env.example b/.env.example index 5ee6f96..c7007a1 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,22 @@ AGENTS_API_PROXY_TOKEN_FILE=~/.parsar/agents-api/web-token # together with AGENTS_API_PROXY_TOKEN_FILE. # AGENTS_API_PROXY_TOKEN= +# Public, non-secret opt-in for the reviewed Codex self_hosted Session profile. +# Leave unset unless Core execution, its executor registry, and executor origin +# are configured. This flag is presentation policy, not capability discovery. +# AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS=1 + +# Optional local-only Docker connection recipe. This renders a copyable command; +# it never gives the browser Docker access or reads the credential file. Every +# value below is compiled into the browser bundle, so values must be non-secret. +# Enable only for the matching operator-controlled local stack. +# AGENTS_CORE_WEB_DOCKER_GUIDE=1 +# AGENTS_CORE_WEB_DOCKER_IMAGE=agents-core-web-executor:2b34ea46-codex-0.153.4 +# AGENTS_CORE_WEB_DOCKER_API_CONTAINER=agents-core-web-api +# AGENTS_CORE_WEB_DOCKER_USER=501:20 +# AGENTS_CORE_WEB_DOCKER_CREDENTIALS_HOME_PATH=.parsar/agents-api-web-smoke/executor-key.json +# AGENTS_CORE_WEB_DOCKER_RUNTIME_HOME_PATH=.parsar/agents-api-web-smoke/executors + # Public, non-secret suggestions shown by the Create Agent model picker. The # first entry is the default unless VITE_AGENT_DEFAULT_MODEL overrides it. These # do not claim live availability; the connected runtime remains authoritative. diff --git a/README.md b/README.md index 898e97a..6a517cc 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ persistence, scheduling, and execution; this project does not embed or reimpleme - Create, view, edit, and delete reusable Agent configurations. - Start durable Sessions and inspect their saved Items. +- Optionally create a Codex `self_hosted` Session and follow the connection state + of an operator-managed Linux executor. - Follow live progress over SSE and recover persisted output after reconnecting. - Cancel active work and return function results or errors. - Use the same Web client with Parsar Core or another proven-compatible Core. @@ -67,12 +69,25 @@ AGENTS_API_PROXY_TARGET=http://127.0.0.1:8091 AGENTS_API_PROXY_TOKEN_FILE=/absolute/private/path/to/web-token ``` -Restart `pnpm dev` after changing them. Keep credentials server-side. A direct -Core URL in the connection dialog is only for a compatible Core that explicitly -allows the Web origin, methods, and headers through CORS. +Self-hosted Session creation is a public, non-secret operator opt-in and is hidden +by default. Enable it only for a reviewed Codex Core deployment whose executor +registry and externally reachable executor origin are configured: + +```dotenv +AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS=1 +``` + +This flag is read when Vite starts or builds the Web. It exposes the supported +Session creation form; it does not probe Core capabilities or prove that an +executor, native runtime, model, or provider is ready. Restart `pnpm dev` after +changing it. Never place an executor key or any other credential in this variable. + +Keep credentials server-side. A direct Core URL in the connection dialog is only +for a compatible Core that explicitly allows the Web origin, methods, and headers +through CORS. Do not have a Core running yet? Use the immutable -[current Parsar setup guide](https://github.com/MiniMax-AI-Dev/parsar/blob/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee/services/agents-api/README.md#standalone-http-service). +[current Parsar setup guide](https://github.com/MiniMax-AI-Dev/parsar/blob/2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e/services/agents-api/README.md#standalone-http-service). The repository's [legacy Web connection runbook](docs/core-connection.md) is pinned to the older revision stated at its top; revalidate its PostgreSQL, caller-key, device, daemon, native-harness, `CODEX_HOME`, verification, and shutdown steps before @@ -81,10 +96,27 @@ applying them to a newer Core. ## First use 1. Open **Agents** and create an Agent with a name, instructions, and model ID. -2. Review, edit, or delete the saved Agent, or start a Session from it. +2. Review, edit, or delete the saved Agent, or start a Session from it. The default + uses no Environment. 3. Open **Sessions**, select the Session, and send a message. 4. Follow live Items, cancel active work, or return a requested function result. +When the operator opt-in is enabled, **Start Session** also offers **Self-hosted**. +Its absolute Workspace path is on the executor host, not in the browser, Web server, +or `parsar-daemon` container. After Core creates the idle Session, Web can show a +launcher template built from that Session's Environment ID and executor origin. The +operator-issued executor credential file stays outside Web, and the launcher itself +runs on caller-managed Linux executor compute. See +[Connecting Agent Core](docs/core-connection.md#optional-self-hosted-session-creation) +for the exact boundary. + +For the reviewed local loopback stack, an operator can additionally enable the +default-off `AGENTS_CORE_WEB_DOCKER_GUIDE=1` profile and its required non-secret +`AGENTS_CORE_WEB_DOCKER_*` settings from `.env.example`. The connection panel then +offers a copyable Docker command alongside the native launcher. Web still never +reads the credential file or talks to Docker, and running the command can release +already queued paid input. + The model ID must be supported by the connected execution runtime. Core currently has no model-catalog endpoint, so Web suggestions are editable hints rather than availability guarantees. Successfully saving an Agent proves configuration storage, @@ -104,6 +136,9 @@ flowchart LR Core, `parsar-daemon`, and native Codex/Claude execution adapters. This repository owns only the open Web experience and `@agents-core-web/agents-client`. The browser connects to the Core protocol; it never uses the daemon WebSocket as its API URL. +For `self_hosted`, a separate operator-managed Linux executor connects to Core with +its own credential and runs commands in its own Workspace; neither Web nor the daemon +container becomes that Environment. See [Architecture](docs/architecture.md) for the full component and trust boundaries. @@ -115,6 +150,7 @@ See [Architecture](docs/architecture.md) for the full component and trust bounda | `401 invalid_api_key` | The plaintext caller bearer must match the current Core key binding | | `503 execution_unavailable` / `Execution is not enabled` | Core rejected execution; inspect its safe error plus runtime and ownership state. A worker, executor, or daemon may be unconfigured or disconnected, or an execution lease may have been lost | | Agent saves but its model fails | Use a model ID and provider credential supported by the connected runtime | +| Self-hosted option is hidden | Set the non-secret `AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS=1` operator flag and restart/rebuild Web only after the connected Codex Core and executor path have been reviewed | `/healthz` proves HTTP liveness only, not chat readiness. Check durable Core state and the current pinned Parsar guide before retrying an uncertain request; the @@ -123,7 +159,7 @@ historical context only. ## Documentation -- [Current Parsar Core setup](https://github.com/MiniMax-AI-Dev/parsar/blob/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee/services/agents-api/README.md#standalone-http-service) — immutable current upstream guide +- [Current Parsar Core setup](https://github.com/MiniMax-AI-Dev/parsar/blob/2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e/services/agents-api/README.md#standalone-http-service) — immutable current upstream guide - [Legacy Web connection runbook](docs/core-connection.md) — historical `0438880` snapshot; revalidate before use - [Protocol coverage](docs/protocol-coverage.md) — exact supported API surface - [Architecture](docs/architecture.md) — ownership, runtime, and trust boundaries diff --git a/README.zh-CN.md b/README.zh-CN.md index badac83..264e278 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -15,6 +15,7 @@ TypeScript 客户端。鉴权、持久化、调度和执行仍由 Core 负责; - 创建、查看、编辑和删除可复用的 Agent 配置。 - 启动持久化 Session,并查看其中保存的 Item。 +- 可选创建 Codex `self_hosted` Session,并查看运维方管理的 Linux executor 连接状态。 - 通过 SSE 查看实时进度,并在重连后恢复已持久化的输出。 - 取消正在执行的任务,并回传函数执行结果或错误。 - 使用同一个 Web 客户端连接 Parsar Core 或其他经验证兼容的 Core。 @@ -67,11 +68,21 @@ AGENTS_API_PROXY_TARGET=http://127.0.0.1:8091 AGENTS_API_PROXY_TOKEN_FILE=/absolute/private/path/to/web-token ``` +Self-hosted Session 创建是默认隐藏的非秘密运维开关。只有已经核对 Codex Core、 +executor registry 和 executor origin 的部署才应启用: + +```dotenv +AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS=1 +``` + +该开关只暴露已支持的表单,不会探测 Core 能力,也不能证明 executor、原生运行时、 +模型或提供商已就绪。不得在其中放入 executor key 或其他凭据。 + 修改后重启 `pnpm dev`。凭据应保留在服务端。只有兼容 Core 通过 CORS 明确允许 Web 的源、方法和请求头时,才能在连接对话框中使用 Core 直连 URL。 还没有运行中的 Core?请使用不可变的 -[当前 Parsar 配置指南](https://github.com/MiniMax-AI-Dev/parsar/blob/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee/services/agents-api/README.md#standalone-http-service)。 +[当前 Parsar 配置指南](https://github.com/MiniMax-AI-Dev/parsar/blob/2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e/services/agents-api/README.md#standalone-http-service)。 仓库内的[旧版 Web 连接手册](docs/core-connection.md)固定在文首标注的旧 revision; 将其中 PostgreSQL、调用方凭据、执行设备、daemon、原生执行适配层(harness)、 `CODEX_HOME`、验证和停止流程用于更新版 Core 前必须重新核对。 @@ -83,6 +94,17 @@ AGENTS_API_PROXY_TOKEN_FILE=/absolute/private/path/to/web-token 3. 打开 **Sessions**,选择 Session 并发送消息。 4. 查看实时 Item、取消正在执行的任务,或回传请求的函数结果。 +启用运维开关后,**Start Session** 还会提供 **Self-hosted**。Workspace 是 executor +主机或容器中的绝对路径,不是浏览器、Web 服务或 daemon 容器的目录。Web 只展示 +Core 返回的 Environment ID、executor origin、连接状态和安全 launcher 模板; +运维方签发的 executor credential 文件始终留在 Web 之外。完整边界见 +[连接 Agent Core](docs/core-connection.md#optional-self-hosted-session-creation)。 + +对于已核对的本地 loopback 栈,还可以配置 `.env.example` 中默认关闭的 +`AGENTS_CORE_WEB_DOCKER_GUIDE=1` 以及完整的非秘密 `AGENTS_CORE_WEB_DOCKER_*` +参数。连接面板会在原生 launcher 之外提供可复制的 Docker 命令;Web 仍不会读取 +credential 文件或访问 Docker。若 Session 已有排队输入,运行命令可能立即触发付费调用。 + model ID 必须由已连接的执行运行时支持。Core 当前没有模型目录接口, 因此 Web 建议项只是可编辑提示,不代表模型一定可用。成功保存 Agent 只能证明 配置已持久化,不能证明 daemon、模型或提供商凭据能够实际执行它。 @@ -112,6 +134,7 @@ WebSocket 当作 API URL。 | `401 invalid_api_key` | 明文调用方 Bearer 凭据必须与 Core 当前的密钥绑定匹配 | | `503 execution_unavailable` / `Execution is not enabled` | Core 拒绝执行;请检查其安全错误、运行时和 ownership 状态。worker、executor 或 daemon 可能未配置或已断连,也可能丢失了执行 lease | | Agent 保存成功但模型运行失败 | 使用已连接运行时支持的 model ID 和提供商凭据 | +| Self-hosted 选项未显示 | 只有核对兼容 Codex Core 与 executor 链路后,设置非秘密 `AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS=1` 并重启或重建 Web | `/healthz` 只能证明 HTTP 存活,不能证明聊天已就绪。重试结果不确定的请求前, 请先核对 Core 持久状态和当前固定版本的 Parsar 指南; @@ -119,7 +142,7 @@ WebSocket 当作 API URL。 ## 文档入口 -- [当前 Parsar Core 配置](https://github.com/MiniMax-AI-Dev/parsar/blob/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee/services/agents-api/README.md#standalone-http-service) — 不可变的当前上游指南 +- [当前 Parsar Core 配置](https://github.com/MiniMax-AI-Dev/parsar/blob/2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e/services/agents-api/README.md#standalone-http-service) — 不可变的当前上游指南 - [旧版 Web 连接手册](docs/core-connection.md) — 历史 `0438880` 快照,使用前必须重新核对 - [协议覆盖范围](docs/protocol-coverage.md) — 准确的已支持 API 范围 - [架构说明](docs/architecture.md) — 所有权、运行时和信任边界 diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts index d1c4167..7c504a5 100644 --- a/apps/web/e2e/agents-lifecycle.spec.ts +++ b/apps/web/e2e/agents-lifecycle.spec.ts @@ -108,6 +108,37 @@ async function openAgents(page: Page, request: APIRequestContext) { await expect(page.getByRole("table", { name: "Agents" })).toBeVisible(); } +async function startSessionWithSecondAgent(page: Page) { + await page.getByRole("button", { name: /Start a Session with Second Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Start an idle Session" }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Create Session" }).click(); +} + +function environmentTrigger(page: Page) { + return page.getByRole("button", { name: /Environment|Connect environment/i }); +} + +function connectedLiveEvents(page: Page) { + return page.getByRole("status", { name: "Session live events: connected" }); +} + +async function openEnvironmentDialog(page: Page) { + const conversation = page.getByRole("tabpanel", { name: "Conversation" }); + await expect(conversation.getByRole("region", { name: "Environment and Workspace status" })).toHaveCount(0); + + const trigger = environmentTrigger(page); + await expect(trigger).toBeVisible(); + await expect(trigger).toHaveAttribute("aria-haspopup", "dialog"); + await trigger.click(); + + const dialog = page.getByRole("dialog", { name: "Environment" }); + await expect(dialog).toBeVisible(); + const panel = dialog.getByRole("region", { name: "Environment and Workspace status" }); + await expect(panel).toBeVisible(); + return { dialog, panel, trigger }; +} + async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) { await testInfo.attach(name, { body: await page.screenshot({ fullPage: true, animations: "disabled" }), @@ -299,8 +330,17 @@ test("continues from a default Agent definition into an admitted idle Session", await expect(page.getByRole("button", { name: "Start Session" })).toBeEnabled(); await page.getByRole("button", { name: "Start Session" }).click(); + const sessionDialog = page.getByRole("dialog", { name: "Start an idle Session" }); + await expect(sessionDialog).toBeVisible(); + await expect(sessionDialog.getByLabel("Saved Agent")).toHaveValue(/^agent_created_/); + await expect(sessionDialog.getByRole("radio", { name: /No environment/ })).toBeChecked(); + await sessionDialog.getByRole("button", { name: "Create Session" }).click(); await expect(page.getByRole("button", { name: "Sessions", exact: true })).toHaveAttribute("aria-current", "page"); - await expect(page.getByRole("region", { name: "Environment and Workspace status" })).toHaveCount(0); + await expect(environmentTrigger(page)).toHaveCount(0); + await expect( + page.getByRole("tabpanel", { name: "Conversation" }) + .getByRole("region", { name: "Environment and Workspace status" }), + ).toHaveCount(0); const sessionCreates = (await fixtureRequests(request)).filter((entry) => ( entry.method === "POST" && entry.path === "/v1/agents/sessions" @@ -372,6 +412,10 @@ test("starts only Agents that pass known Session admission", async ({ page, requ entry.method === "POST" && entry.path === "/v1/agents/sessions" )).length; await page.getByRole("button", { name: "Start a Session with Second Agent" }).click(); + const sessionDialog = page.getByRole("dialog", { name: "Start an idle Session" }); + await expect(sessionDialog).toBeVisible(); + await expect(sessionDialog.getByLabel("Saved Agent")).toHaveValue("agent_b"); + await sessionDialog.getByRole("button", { name: "Create Session" }).click(); await expect(page.getByRole("button", { name: "Sessions", exact: true })).toHaveAttribute("aria-current", "page"); const sessionCreates = (await fixtureRequests(request)).filter((entry) => ( @@ -385,6 +429,64 @@ test("starts only Agents that pass known Session admission", async ({ page, requ }); }); +test("creates the bounded self-hosted profile and renders a secret-free connection guide", async ({ page, request }) => { + await openAgents(page, request); + const sessionPostsBefore = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )).length; + + await page.getByRole("button", { name: "Start a Session with Second Agent" }).click(); + const dialog = page.getByRole("dialog", { name: "Start an idle Session" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByLabel("Saved Agent")).toHaveValue("agent_b"); + await dialog.getByRole("radio", { name: /Self-hosted/ }).check(); + const workspace = dialog.getByLabel("Workspace directory"); + await workspace.fill("relative/workspace"); + await expect(dialog.getByText("Workspace directory must be an absolute POSIX path")).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Create Session" })).toBeDisabled(); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + ))).toHaveLength(sessionPostsBefore); + + await workspace.fill("/executor/workspace"); + await expect(dialog.getByRole("button", { name: "Create Session" })).toBeEnabled(); + await dialog.getByRole("button", { name: "Create Session" }).click(); + + const { dialog: environmentDialog, panel: environmentPanel, trigger } = await openEnvironmentDialog(page); + await expect(environmentPanel).toBeVisible(); + await expect(environmentPanel).toContainText("Self-hosted Environment"); + await expect(environmentPanel).toContainText("0f745b0d-b545-49cd-8d7e-4c31c80dc564"); + await expect(environmentPanel).toContainText("https://executor.example.test"); + await expect(environmentPanel).toContainText("/executor/workspace"); + await expect(environmentPanel).toContainText("Pending"); + + await expect(environmentPanel.locator("details.environment-launcher-guide")).toHaveAttribute("open", ""); + await expect(environmentPanel.getByRole("button", { name: "Copy native command" })).toBeVisible(); + await expect(environmentPanel).toContainText("agents-api-codex-executor"); + await expect(environmentPanel).toContainText("$HOME/.parsar/executor-key.json"); + await expect(environmentPanel).toContainText("Web copies its path but never creates, reads, stores, or transmits the key"); + await expect(environmentPanel).not.toContainText("executor_token"); + await expect(environmentPanel).not.toContainText("Authorization: Bearer"); + + await page.keyboard.press("Escape"); + await expect(environmentDialog).toHaveCount(0); + await expect(trigger).toBeFocused(); + + const requests = await fixtureRequests(request); + const sessionCreates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions"); + expect(sessionCreates).toHaveLength(sessionPostsBefore + 1); + expect(sessionCreates.at(-1)?.body).toEqual({ + agent_id: "agent_b", + environment: { + type: "self_hosted", + workspace_directory: "/executor/workspace", + capability_directories: [], + }, + stream: false, + }); + expect(requests.filter((entry) => entry.method === "POST" && entry.path.endsWith("/events"))).toHaveLength(0); +}); + test("keeps the New Session reason keyboard-accessible when every loaded Agent is incompatible", async ({ page, request }) => { await openAgents(page, request); const deleted = await request.delete(`${fixtureBaseUrl}/v1/agents/agent_b`); @@ -585,7 +687,7 @@ test("keeps the Agent ledger and dialogs usable at 390 px in light and dark mode test("starts one Session with an idempotency key and without browser authorization", async ({ page, request }) => { await openAgents(page, request); - await page.getByRole("button", { name: /Start a Session with Second Agent/ }).click(); + await startSessionWithSecondAgent(page); await expect(page.getByRole("heading", { name: "Sessions" })).toBeVisible(); const requests = await fixtureRequests(request); @@ -599,10 +701,42 @@ test("starts one Session with an idempotency key and without browser authorizati } }); +test("keeps one Session create attempt across response loss and an unchanged manual retry", async ({ page, request }) => { + await openAgents(page, request); + await page.getByRole("button", { name: /Start a Session with Second Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Start an idle Session" }); + const create = dialog.getByRole("button", { name: "Create Session" }); + await controlFixture(request, { sessionCreateDelayMs: 1_500, sessionCreateResponseLoss: 1 }); + + await create.evaluate((button) => { + button.click(); + button.click(); + }); + await expect(dialog.getByRole("alert")).toContainText("Agent core request failed (502)."); + await expect(dialog.getByText("Retrying this unchanged form reuses the original idempotency key.")).toBeVisible(); + + let creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(1); + const originalKey = creates[0]?.idempotencyKey; + expect(originalKey).toBeTruthy(); + expect((await fixtureState(request)).sessions).toHaveLength(2); + + await create.click(); + await expect(page.getByRole("heading", { name: "Sessions" })).toBeVisible(); + creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(2); + expect(creates[1]?.idempotencyKey).toBe(originalKey); + expect((await fixtureState(request)).sessions).toHaveLength(2); +}); + test("shows composer activity only for a Core-reported in-progress Session", async ({ page, request }, testInfo) => { await resetFixture(request); await page.goto("/"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); const activity = page.locator(".conversation-activity"); await expect(activity).toHaveCount(0); @@ -620,10 +754,51 @@ test("shows composer activity only for a Core-reported in-progress Session", asy await expect(page.getByRole("button", { name: "Send message" })).toBeVisible(); }); +test("shows immediate local feedback while a message submission is waiting for Core", async ({ page, request }) => { + await resetFixture(request); + await page.goto("/"); + await expect(connectedLiveEvents(page)).toBeVisible(); + + let releaseSend: (() => void) | undefined; + const sendGate = new Promise((resolve) => { + releaseSend = resolve; + }); + let interceptedSends = 0; + await page.route("**/v1/agents/sessions/*/events", async (route) => { + if (route.request().method() !== "POST") { + await route.continue(); + return; + } + interceptedSends += 1; + await sendGate; + await route.continue(); + }); + + const composer = page.getByLabel("Message the Agent"); + await composer.fill("Immediate pending message"); + await composer.press("Enter"); + + const pending = page.locator('[data-send-state="sending"]'); + await expect(pending).toBeVisible(); + await expect(pending).toContainText("Immediate pending message"); + await expect(pending).toContainText("Sending…"); + await expect(page.locator(".conversation-activity")).toContainText("Sending message…"); + await expect(page.getByText("Lifecycle Agent is working…")).toHaveCount(0); + await expect(composer).toHaveValue(""); + await expect.poll(() => interceptedSends).toBe(1); + + releaseSend?.(); + await expect(pending).toHaveCount(0); + await expect(page.locator(".conversation-activity")).toHaveCount(0); + await expect.poll(async () => (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ).length).toBe(1); +}); + test("updates Session title and metadata after a latest read while preserving failed and unknown drafts", async ({ page, request }) => { await resetFixture(request); await page.goto("/"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); const manage = page.locator(".conversation-session-action"); const streamReadsBefore = (await fixtureRequests(request)).filter((entry) => ( entry.method === "GET" && entry.path.endsWith("/events") @@ -685,7 +860,7 @@ test("updates Session title and metadata after a latest read while preserving fa test("preserves and safely rebases a Session metadata draft after a same-key conflict", async ({ page, request }) => { await resetFixture(request); await page.goto("/"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); await page.locator(".conversation-session-action").click(); const dialog = page.getByRole("dialog"); await expect(dialog.getByRole("button", { name: "Edit", exact: true })).toBeEnabled(); @@ -720,7 +895,7 @@ test("preserves and safely rebases a Session metadata draft after a same-key con test("rejects wrong-id and deep-malformed Session reads before writes or delete retries", async ({ page, request }) => { await resetFixture(request); await page.goto("/"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); await controlFixture(request, { sessionRetrieveVariant: "wrong_id" }); await page.locator(".conversation-session-action").click(); @@ -760,7 +935,7 @@ test("rejects wrong-id and deep-malformed Session reads before writes or delete test("requires confirmation and reconciles unknown Session deletes once without retrying the write", async ({ page, request }) => { await resetFixture(request); await page.goto("/"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); const manage = page.locator(".conversation-session-action"); await manage.click(); const dialog = page.getByRole("dialog"); @@ -838,7 +1013,7 @@ test("requires confirmation and reconciles unknown Session deletes once without test("keeps a stale Session row and surfaces each explicit repeated 404 deletion", async ({ page, request }) => { await resetFixture(request); await page.goto("/"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); const manage = page.locator(".conversation-session-action"); await manage.click(); const dialog = page.getByRole("dialog"); @@ -854,14 +1029,14 @@ test("keeps a stale Session row and surfaces each explicit repeated 404 deletion } }); -test("deletes an inactive Session without disturbing the active composer or listening stream", async ({ page, request }) => { +test("deletes an inactive Session without disturbing the active composer or live event stream", async ({ page, request }) => { await resetFixture(request); await page.goto("/"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); await page.getByRole("button", { name: "Agents" }).click(); - await page.getByRole("button", { name: /Start a Session with Second Agent/ }).click(); + await startSessionWithSecondAgent(page); await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); const composer = page.getByLabel("Message the Agent"); await composer.fill("active draft must survive"); const before = await fixtureState(request); @@ -888,7 +1063,7 @@ test("deletes an inactive Session without disturbing the active composer or list await expect(dialog).toHaveCount(0); await expect(inactiveRow).toHaveCount(0); await expect(composer).toHaveValue("active draft must survive"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); await expect(page.locator(".conversation-session-action")).toBeFocused(); const after = await fixtureState(request); @@ -926,14 +1101,14 @@ for (const pendingRead of [ }); await resetFixture(request); await page.goto("/"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); await page.getByRole("button", { name: "Agents" }).click(); - await page.getByRole("button", { name: /Start a Session with Second Agent/ }).click(); + await startSessionWithSecondAgent(page); await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); await controlFixture(request, { turnsScenario: 1 }); await page.locator(".session-row").filter({ hasText: "Lifecycle Agent" }).locator(".session-row-select").click(); await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); await expect(page.getByText("Completed Turn output remains in the conversation.")).toBeVisible(); await page.locator(".conversation-session-action").click(); const dialog = page.getByRole("dialog"); @@ -956,7 +1131,7 @@ for (const pendingRead of [ await expect(page.getByText("Completed Turn output remains in the conversation.")).toHaveCount(0); await expect(page.locator('[data-turn-id="turn_completed"]')).toHaveCount(0); await expect(page.locator(".conversation-session-action")).toBeFocused(); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); await expect.poll(() => failedReads.get(pendingRead.path)).toContain("ERR_ABORTED"); await expect.poll(async () => (await fixtureState(request)).aborts.streams).toBeGreaterThan(before.aborts.streams); @@ -969,7 +1144,7 @@ for (const pendingRead of [ test("aborts a pending manual recovery read after deleting the selected Session", async ({ page, request }) => { await resetFixture(request); await page.goto("/"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); await controlFixture(request, { sessionRetrieveDelayMs: 5_000 }); await expectSelectedDeleteAbortsSessionRead(page, request, () => ( @@ -980,7 +1155,7 @@ test("aborts a pending manual recovery read after deleting the selected Session" test("aborts a pending detail retry read after deleting the selected Session", async ({ page, request }) => { await resetFixture(request); await page.goto("/"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); await controlFixture(request, { sessionRetrieveStatus: 503 }); await page.getByRole("button", { name: "Recover durable state" }).click(); const detailError = page.locator(".session-detail-error"); @@ -995,16 +1170,16 @@ test("aborts a pending detail retry read after deleting the selected Session", a test("deletes the selected Session while its SSE is still connecting", async ({ page, request }) => { await resetFixture(request); await page.goto("/"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); await page.getByRole("button", { name: "Agents" }).click(); - await page.getByRole("button", { name: /Start a Session with Second Agent/ }).click(); + await startSessionWithSecondAgent(page); await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); await controlFixture(request, { streamOpenDelayMs: 3_000 }); await page.locator(".session-row").filter({ hasText: "Lifecycle Agent" }).locator(".session-row-select").click(); await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); - await expect(page.getByText("connecting", { exact: true })).toBeVisible(); + await expect(page.getByText("Connecting events…", { exact: true })).toBeVisible(); const before = await fixtureState(request); await page.locator(".conversation-session-action").click(); @@ -1022,7 +1197,7 @@ test("keeps Session actions accessible and contained at 390 px in dark mode", as await page.setViewportSize({ width: 390, height: 844 }); await resetFixture(request); await page.goto("/"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); await page.getByRole("button", { name: "Dark theme" }).click(); const manage = page.locator(".conversation-session-action"); await manage.focus(); @@ -1067,9 +1242,10 @@ test("renders self-hosted Environment and Workspace state safely across reconnec streamCloseDelayMs: 1_000, }); await page.goto("/"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); - const panel = page.getByRole("region", { name: "Environment and Workspace status" }); + const { dialog, panel, trigger } = await openEnvironmentDialog(page); + await expect(trigger).toHaveAccessibleName("Environment pending"); await expect(panel).toContainText("Self-hosted Environment"); await expect(panel).toContainText("Pending"); await expect(panel).toContainText("environment_fixture"); @@ -1097,10 +1273,14 @@ test("renders self-hosted Environment and Workspace state safely across reconnec await attachElementScreenshot(panel, testInfo, "desktop-light-environment-panel"); await attachScreenshot(page, testInfo, "desktop-light-self-hosted-environment"); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + await expect(trigger).toBeFocused(); await page.setViewportSize({ width: 390, height: 844 }); await page.getByRole("button", { name: "Dark theme" }).click(); await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); - const widths = await panel.evaluate((element) => { + const { panel: narrowPanel } = await openEnvironmentDialog(page); + const widths = await narrowPanel.evaluate((element) => { const box = element.getBoundingClientRect(); return { viewport: innerWidth, @@ -1114,15 +1294,16 @@ test("renders self-hosted Environment and Workspace state safely across reconnec expect(widths.body).toBeLessThanOrEqual(widths.viewport); expect(widths.left).toBeGreaterThanOrEqual(0); expect(widths.right).toBeLessThanOrEqual(390); - await panel.getByRole("link", { name: "Launcher setup" }).focus(); - await expect(panel.getByRole("link", { name: "Launcher setup" })).toBeFocused(); - await panel.evaluate((element) => element.scrollIntoView({ block: "start" })); - await attachElementScreenshot(panel, testInfo, "narrow-dark-environment-panel"); + await narrowPanel.getByRole("link", { name: "Launcher setup" }).focus(); + await expect(narrowPanel.getByRole("link", { name: "Launcher setup" })).toBeFocused(); + await narrowPanel.evaluate((element) => element.scrollIntoView({ block: "start" })); + await attachElementScreenshot(narrowPanel, testInfo, "narrow-dark-environment-panel"); await attachScreenshot(page, testInfo, "narrow-dark-self-hosted-environment"); await controlFixture(request, { environmentScenario: 2, environmentEventStatus: 0 }); await page.reload(); - const unknown = page.getByRole("region", { name: "Environment and Workspace status" }); + const { panel: unknown, trigger: unknownTrigger } = await openEnvironmentDialog(page); + await expect(unknownTrigger).toHaveAccessibleName("Environment unavailable"); await expect(unknown).toContainText("Environment unavailable"); await expect(unknown).toContainText("Unknown type"); await expect(unknown).not.toContainText("/must-not-render"); @@ -1130,7 +1311,7 @@ test("renders self-hosted Environment and Workspace state safely across reconnec await controlFixture(request, { environmentScenario: 3, environmentEventStatus: 0 }); await page.reload(); - const missing = page.getByRole("region", { name: "Environment and Workspace status" }); + const { panel: missing } = await openEnvironmentDialog(page); await expect(missing).toContainText("ID unavailable"); await expect(missing).toContainText("unsafe or malformed URL"); }); @@ -1143,9 +1324,10 @@ test("hydrates durable expired and unavailable Environment states without a writ environmentEventStatus: 0, }); await page.goto("/"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); - const panel = page.getByRole("region", { name: "Environment and Workspace status" }); + let { panel, trigger } = await openEnvironmentDialog(page); + await expect(trigger).toHaveAccessibleName("Environment expired"); await expect(panel).toContainText("Expired"); await expect(panel).toContainText("Environment expired"); await expect(panel).toContainText("no API-managed files, plugins, or skills"); @@ -1169,6 +1351,8 @@ test("hydrates durable expired and unavailable Environment states without a writ await controlFixture(request, { environmentRetrieveStatus: 503 }); await page.reload(); + ({ panel, trigger } = await openEnvironmentDialog(page)); + await expect(trigger).toHaveAccessibleName("Environment unavailable"); await expect(panel).toContainText("Unavailable"); await expect(panel).toContainText("conversation remains usable"); await expect(panel).not.toContainText("Expired"); @@ -1180,6 +1364,8 @@ test("hydrates durable expired and unavailable Environment states without a writ environmentResourceVariant: "missing_skills", }); await page.reload(); + ({ panel, trigger } = await openEnvironmentDialog(page)); + await expect(trigger).toHaveAccessibleName("Environment unavailable"); await expect(panel).toContainText("Unavailable"); await expect(page.getByLabel("Message the Agent")).toBeVisible(); @@ -1198,10 +1384,11 @@ test("hydrates durable Environment state even when the live stream is rejected", }); await page.goto("/"); - const panel = page.getByRole("region", { name: "Environment and Workspace status" }); + const { panel, trigger } = await openEnvironmentDialog(page); + await expect(trigger).toHaveAccessibleName("Environment expired"); await expect(panel).toContainText("Expired"); await expect(panel).toContainText("Status comes from the durable Environment resource"); - await expect(page.getByText("failed", { exact: true })).toBeVisible(); + await expect(page.getByText("Events unavailable", { exact: true })).toBeVisible(); await expect(page.getByLabel("Message the Agent")).toBeVisible(); const requests = await fixtureRequests(request); @@ -1220,7 +1407,8 @@ test("keeps canonical Environment UUID identity across Session and resource proj }); await page.goto("/"); - const panel = page.getByRole("region", { name: "Environment and Workspace status" }); + const { panel, trigger } = await openEnvironmentDialog(page); + await expect(trigger).toHaveAccessibleName("Environment connected"); await expect(panel).toContainText("Connected"); await expect(panel).toContainText("Status comes from the durable Environment resource"); await expect(panel).not.toContainText("Durable Environment status is unavailable"); @@ -1247,7 +1435,8 @@ test("applies a buffered live Environment event after an earlier durable snapsho }); await page.goto("/"); - const panel = page.getByRole("region", { name: "Environment and Workspace status" }); + const { panel, trigger } = await openEnvironmentDialog(page); + await expect(trigger).toHaveAccessibleName("Environment connected"); await expect(panel).toContainText("Connected"); await expect(panel).toContainText("last supported live event observed after the durable Environment snapshot"); await expect(panel).not.toContainText("Pending"); @@ -1260,7 +1449,7 @@ test("loads every Turn page, reconciles terminal events, and keeps diagnostics o turnsPageSize: 2, }); await page.goto("/"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); const conversationTab = page.getByRole("tab", { name: "Conversation" }); const conversation = page.getByRole("tabpanel", { name: "Conversation" }); @@ -1351,7 +1540,7 @@ test("presents an honest searchable Trace workbench without changing the convers itemsScenario: 2, }); await page.goto("/"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); const viewTabs = page.getByRole("tablist", { name: "Session view" }); const conversationTab = viewTabs.getByRole("tab", { name: "Conversation" }); @@ -1473,7 +1662,7 @@ test("drops a delayed Turn page after switching Sessions", async ({ page, reques await expect(timeline).toContainText("Loading every Turn page"); await page.getByRole("button", { name: "Agents" }).click(); await expect(page.getByRole("table", { name: "Agents" })).toBeVisible(); - await page.getByRole("button", { name: /Start a Session with Second Agent/ }).click(); + await startSessionWithSecondAgent(page); await page.getByRole("tab", { name: "Trace" }).click(); const nextDiagnostics = page.locator("details.trace-turn-diagnostics"); @@ -1495,7 +1684,7 @@ test("renders Parsar patches as accessible read-only diffs in desktop and narrow await resetFixture(request); await controlFixture(request, { itemsScenario: 1 }); await page.goto("/"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); const completedTrace = page.locator('[data-work-trace="completed"]'); await completedTrace.getByRole("button", { name: /Completed/ }).click(); @@ -1544,7 +1733,7 @@ test("renders Parsar patches as accessible read-only diffs in desktop and narrow test("manually retries uncertain sends with the original key only while the payload is unchanged", async ({ page, request }, testInfo) => { await resetFixture(request); await page.goto("/"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); const composer = page.getByLabel("Message the Agent"); await controlFixture(request, { sendResponseLoss: 1 }); @@ -1593,7 +1782,7 @@ test("keeps cancellation available for an Environment-only required action", asy await resetFixture(request); await controlFixture(request, { environmentScenario: 6 }); await page.goto("/"); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(connectedLiveEvents(page)).toBeVisible(); await expect(page.getByRole("region", { name: "Environment connection required" })).toBeVisible(); await expect(page.getByRole("region", { name: "Function result required" })).toHaveCount(0); const writesBefore = (await fixtureRequests(request)).filter( diff --git a/apps/web/e2e/fixture-core.mjs b/apps/web/e2e/fixture-core.mjs index 65008fc..e00c683 100644 --- a/apps/web/e2e/fixture-core.mjs +++ b/apps/web/e2e/fixture-core.mjs @@ -159,6 +159,37 @@ function sessionSnapshot(agent) { return snapshot; } +function sessionEnvironmentResponse(environment) { + if (!isRecord(environment) || typeof environment.type !== "string") return null; + if (environment.type === "none") { + return hasOnlyKeys(environment, ["type"]) ? { type: "none" } : null; + } + if ( + environment.type !== "self_hosted" + || !hasOnlyKeys(environment, ["type", "workspace_directory", "capability_directories"]) + ) return null; + + const workspace = environment.workspace_directory; + const capabilities = environment.capability_directories; + if ( + typeof workspace !== "string" + || !workspace + || workspace.startsWith("~") + || !workspace.startsWith("/") + || /[\0\r\n\\]/u.test(workspace) + || capabilities !== undefined && capabilities !== null + && (!Array.isArray(capabilities) || capabilities.length !== 0) + ) return null; + + return { + type: "self_hosted", + id: canonicalEnvironmentUuid, + remote_url: "https://executor.example.test", + workspace_directory: workspace, + capability_directories: [], + }; +} + function initialState() { const first = savedAgent("agent_a", "Lifecycle Agent", "fixture/model-a", baseline - 60); const second = savedAgent("agent_b", "Second Agent", "fixture/model-b", baseline - 30); @@ -182,8 +213,12 @@ function initialState() { }], turns: [], requests: [], + sessionCreateReceipts: new Map(), controls: { createAgentResponseVariant: "valid", + sessionCreateDelayMs: 0, + sessionCreateStatus: 201, + sessionCreateResponseLoss: 0, retrieveDelayMs: 0, retrieveStatus: 200, updateDelayMs: 0, @@ -381,11 +416,11 @@ function recordRequest(request, url, body) { }); } -function consumeControl(prefix) { +function consumeControl(prefix, successStatus = 200) { const delayMs = state.controls[`${prefix}DelayMs`]; const status = state.controls[`${prefix}Status`]; state.controls[`${prefix}DelayMs`] = 0; - state.controls[`${prefix}Status`] = 200; + state.controls[`${prefix}Status`] = successStatus; return { delayMs, status }; } @@ -497,16 +532,35 @@ const server = http.createServer(async (request, response) => { } if (request.method === "POST" && url.pathname === "/v1/agents/sessions") { + const idempotencyKey = request.headers["idempotency-key"]; + const fingerprint = JSON.stringify(body); + const receipt = typeof idempotencyKey === "string" + ? state.sessionCreateReceipts.get(idempotencyKey) + : undefined; + if (receipt) { + if (receipt.fingerprint !== fingerprint) { + return sendError(response, 409, "Fixture idempotency key was reused with a different Session request."); + } + return sendJson(response, receipt.session, 200); + } + + const control = consumeControl("sessionCreate", 201); + const responseLoss = state.controls.sessionCreateResponseLoss; + state.controls.sessionCreateResponseLoss = 0; + if (control.delayMs) await wait(control.delayMs); + if (control.status !== 201) return sendError(response, control.status, "Fixture Session create failed."); const agent = state.agents.find((candidate) => candidate.id === body.agent_id); if (!agent) return sendError(response, 404, "Fixture Agent not found for Session."); const admissionError = sessionAdmissionError(agent); if (admissionError) return sendError(response, 400, admissionError); + const environment = sessionEnvironmentResponse(body.environment); + if (!environment) return sendError(response, 400, "Fixture Session environment is unsupported."); state.sequence += 1; const created = { id: `session_created_${state.sequence}`, object: "agent.session", agent: sessionSnapshot(agent), - environment: body.environment, + environment, status: "idle", error: null, metadata: body.metadata ?? {}, @@ -517,6 +571,13 @@ const server = http.createServer(async (request, response) => { last_active_at: baseline + state.sequence, }; state.sessions.unshift(created); + if (typeof idempotencyKey === "string") { + state.sessionCreateReceipts.set(idempotencyKey, { fingerprint, session: created }); + } + if (responseLoss) { + response.destroy(); + return; + } return sendJson(response, created, 201); } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c9fb58e..d2f0916 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -34,6 +34,8 @@ import { type SessionDetailState, type StreamState, } from "./features/sessions/SessionsView"; +import type { SessionStartInput } from "./features/sessions/create/SessionStartDialog"; +import { sessionEnvironmentInput } from "./features/sessions/create/session-environment"; import { removeSession, reconcileUnknownSessionDelete, @@ -111,6 +113,11 @@ interface SelectedSessionLoad { error: string | null; } +interface SessionCreateRequest { + agentId: string | null; + requestId: number; +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : "The Agent core request failed."; } @@ -209,7 +216,7 @@ export function App() { ); const [busy, setBusy] = useState(false); const [agentCreateRequest, setAgentCreateRequest] = useState(null); - const [sessionCreateRequest, setSessionCreateRequest] = useState(null); + const [sessionCreateRequest, setSessionCreateRequest] = useState(null); const agentCreateSequenceRef = useRef(0); const sessionCreateSequenceRef = useRef(0); const selectedIdRef = useRef(selectedId); @@ -868,19 +875,42 @@ export function App() { setAgents((current) => removeSavedAgent(current, agentId)); }; - const createSession = async (agentId: string) => { - const savedAgent = agents.find((agent) => agent.id === agentId); + const createSession = async (input: SessionStartInput) => { + const savedAgent = agents.find((agent) => agent.id === input.agentId); const admissionBlocker = savedAgent ? knownSessionAdmissionBlocker(savedAgent) : "The selected saved Agent is not loaded."; if (admissionBlocker) { const error = new Error(`Session was not created. ${admissionBlocker}`); notify(error.message, "error"); throw error; } - const session = await run( - () => core.createSession({ agent_id: agentId, environment: { type: "none" }, stream: false }), - "Idle Session created. Opening live events…", - ); - if (!session || coreGeneration !== connectionGenerationRef.current) return; + if (input.environment.type === "self_hosted" && !__AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS__) { + const error = new Error("Session was not created. Self-hosted Sessions are not enabled for this Web build."); + notify(error.message, "error"); + throw error; + } + const rawEnvironment = input.environment as unknown as Record; + const environmentType = rawEnvironment.type; + const workspaceDirectory = typeof rawEnvironment.workspace_directory === "string" + ? rawEnvironment.workspace_directory + : ""; + const normalizedEnvironment = environmentType === "self_hosted" + ? sessionEnvironmentInput("self_hosted", workspaceDirectory) + : environmentType === "none" + ? sessionEnvironmentInput("none", "") + : sessionEnvironmentInput(environmentType, ""); + const environmentInput = normalizedEnvironment.input; + if (!environmentInput) { + const error = new Error(`Session was not created. ${normalizedEnvironment.error ?? "The Environment input is invalid."}`); + notify(error.message, "error"); + throw error; + } + const session = await run(() => core.createSession( + { agent_id: input.agentId, environment: environmentInput, stream: false }, + input.idempotencyKey, + )); + if (!session || coreGeneration !== connectionGenerationRef.current) { + throw new Error("The Session creation outcome could not be confirmed."); + } sessionCollectionRevisionRef.current += 1; setSessions((current) => [session, ...current.filter((value) => value.id !== session.id)]); setSelectedId(session.id); @@ -888,6 +918,7 @@ export function App() { itemsSessionIdRef.current = session.id; setItemsSessionId(session.id); setView("sessions"); + notify("Idle Session created. Opening live events…", "success"); }; const retrieveSessionForAction = useCallback(async (sessionId: string) => { @@ -1133,10 +1164,18 @@ export function App() { setAgentCreateRequest(agentCreateSequenceRef.current); }; - const openSessionSetup = () => { + const openSessionSetup = (agentId?: string) => { + if (agentId) { + const agent = agents.find((candidate) => candidate.id === agentId); + const blocker = agent ? knownSessionAdmissionBlocker(agent) : "The selected saved Agent is not loaded."; + if (blocker) { + notify(`Session was not created. ${blocker}`, "error"); + return; + } + } setView("sessions"); sessionCreateSequenceRef.current += 1; - setSessionCreateRequest(sessionCreateSequenceRef.current); + setSessionCreateRequest({ agentId: agentId ?? null, requestId: sessionCreateSequenceRef.current }); }; const consumeAgentCreateRequest = useCallback((request: number) => { @@ -1144,7 +1183,7 @@ export function App() { }, []); const consumeSessionCreateRequest = useCallback((request: number) => { - setSessionCreateRequest((current) => current === request ? null : current); + setSessionCreateRequest((current) => current?.requestId === request ? null : current); }, []); return ( @@ -1205,7 +1244,7 @@ export function App() { canCreateAgent={agentCollectionState === "ready" && !busy} canStartSession={sessionCollectionState === "ready" && agents.some((agent) => !knownSessionAdmissionBlocker(agent)) && !busy} onCreateAgent={openAgentSetup} - onStartSession={openSessionSetup} + onStartSession={() => openSessionSetup()} />
@@ -1220,7 +1259,7 @@ export function App() { busy={busy} coreError={sessionCollectionError} coreState={sessionCollectionState} - createRequest={sessionCreateRequest ?? 0} + createRequest={sessionCreateRequest} onCreateRequestConsumed={consumeSessionCreateRequest} detailError={detailError} detailState={detailState} @@ -1230,6 +1269,7 @@ export function App() { sendError={sendError} streamError={streamError} streamState={streamState} + selfHostedEnabled={__AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS__} onCancel={cancel} onCreateSession={createSession} onDeleteSession={deleteSessionFromCore} @@ -1259,7 +1299,7 @@ export function App() { onDelete={deleteAgent} onRefresh={() => void refreshAgents()} onRetrieve={retrieveAgent} - onStartSession={createSession} + onStartSession={openSessionSetup} onUpdate={updateAgent} /> ) : null} diff --git a/apps/web/src/components/ConnectionModal.test.tsx b/apps/web/src/components/ConnectionModal.test.tsx index 7c07ea6..05abe1a 100644 --- a/apps/web/src/components/ConnectionModal.test.tsx +++ b/apps/web/src/components/ConnectionModal.test.tsx @@ -93,7 +93,7 @@ describe("Agent Core connection modes", () => { expect(markup).toContain("Legacy troubleshooting · 043 snapshot"); expect(markup).toContain("Parsar Core setup"); expect(markup).toContain("98c5b3312ad33e1fae8b94283a011eb3e5f4ee2c"); - expect(markup).toContain("d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee"); + expect(markup).toContain("2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e"); expect(markup).not.toContain("0438880ab21aa16d05cb91a4c7f91cc0abc12358"); expect(markup).not.toContain("f7cdf591396529880d80f8211fc7a0f4768fdf46"); expect(markup).not.toContain("8cc2898ca42b272cb3771234ee6a0ad0d2e932ba"); diff --git a/apps/web/src/components/ConnectionModal.tsx b/apps/web/src/components/ConnectionModal.tsx index a63ac08..c30a1ba 100644 --- a/apps/web/src/components/ConnectionModal.tsx +++ b/apps/web/src/components/ConnectionModal.tsx @@ -29,7 +29,7 @@ export type ConnectionProbeState = | { status: "complete"; result: CoreProbeResult }; const webBaseline = "98c5b3312ad33e1fae8b94283a011eb3e5f4ee2c"; -const parsarBaseline = "d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee"; +const parsarBaseline = "2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e"; const operatorGuideUrl = `https://github.com/MiniMax-AI-Dev/agents-core-web/blob/${webBaseline}/docs/core-connection.md`; const troubleshootingUrl = `${operatorGuideUrl}#troubleshooting`; const parsarCoreSetupUrl = `https://github.com/MiniMax-AI-Dev/parsar/blob/${parsarBaseline}/services/agents-api/README.md#standalone-http-service`; diff --git a/apps/web/src/features/CoreCollectionStates.test.tsx b/apps/web/src/features/CoreCollectionStates.test.tsx index ebaad7a..230e7ae 100644 --- a/apps/web/src/features/CoreCollectionStates.test.tsx +++ b/apps/web/src/features/CoreCollectionStates.test.tsx @@ -197,7 +197,7 @@ describe("Agent Core collection states", () => { expect(failed).toContain("Couldn’t refresh Sessions"); expect(failed).toContain("sessions refresh failed"); expect(failed).toContain("Existing durable item"); - expect(failed).toContain("listening"); + expect(failed).toContain("Live events"); expect(composer).not.toContain("disabled"); }); @@ -335,7 +335,7 @@ describe("Agent Core collection states", () => { expect(failed).toContain("Your draft was restored and was not retried."); expect(failed).toContain("operator runtime configuration"); expect(failed).toContain("Core runtime setup"); - expect(failed).toContain("d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee"); + expect(failed).toContain("2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e"); expect(failed).not.toContain("AGENTS_API_DAEMON_WS_URL"); }); @@ -378,6 +378,10 @@ describe("Agent Core collection states", () => { ); expect(waiting).toContain('aria-label="Environment connection required"'); + expect(waiting).toContain('aria-haspopup="dialog"'); + expect(waiting).toContain('aria-label="Connect environment"'); + expect(waiting).toContain("Live events"); + expect(waiting).not.toContain('aria-label="Environment and Workspace status"'); expect(waiting).toContain("environment_01"); expect(waiting).toContain("must be connected by the Core operator"); expect(waiting).toContain('aria-label="Function result required"'); diff --git a/apps/web/src/features/agents/AgentSetupView.tsx b/apps/web/src/features/agents/AgentSetupView.tsx index c3fe818..b3bf6ad 100644 --- a/apps/web/src/features/agents/AgentSetupView.tsx +++ b/apps/web/src/features/agents/AgentSetupView.tsx @@ -41,7 +41,7 @@ function SetupGuide({ saved }: { saved: boolean }) { const steps = [ ["Define an Agent", "Choose a model and instructions; the Web keeps generation settings on the current Session-safe profile.", true], ["Save the definition", "Core becomes the durable source of truth for the saved Agent.", saved], - ["Start a Session", "Create an idle environment:none Session and subscribe before sending input.", false], + ["Start a Session", "Choose a supported Environment profile, create an idle Session, and subscribe before sending input.", false], ["Exchange events", "A real Turn still requires a compatible worker, executor, model, and provider.", false], ] as const; return ( diff --git a/apps/web/src/features/agents/AgentsView.tsx b/apps/web/src/features/agents/AgentsView.tsx index 9150246..abb7ac8 100644 --- a/apps/web/src/features/agents/AgentsView.tsx +++ b/apps/web/src/features/agents/AgentsView.tsx @@ -24,7 +24,7 @@ interface AgentsViewProps { onDelete?: (agentId: string) => Promise; onRefresh: () => void; onRetrieve?: (agentId: string) => Promise; - onStartSession: (agentId: string) => Promise; + onStartSession: (agentId: string) => void; onUpdate?: (agentId: string, input: UpdateAgentInput) => Promise; } @@ -300,7 +300,7 @@ export function AgentsView({ }; const startSession = (agentId: string) => { - void onStartSession(agentId).catch(() => undefined); + onStartSession(agentId); }; const dialogTitle = mode === "create" diff --git a/apps/web/src/features/sessions/SessionsView.tsx b/apps/web/src/features/sessions/SessionsView.tsx index 3adb482..9d99167 100644 --- a/apps/web/src/features/sessions/SessionsView.tsx +++ b/apps/web/src/features/sessions/SessionsView.tsx @@ -6,6 +6,7 @@ import { Code2, Ellipsis, ExternalLink, + HardDrive, MessageSquare, Plus, RefreshCw, @@ -26,18 +27,27 @@ import type { import type { FailedPendingSend } from "../../lib/pending-send"; import { ErrorState } from "../../components/ErrorState"; -import { Modal } from "../../components/Modal"; import { Skeleton } from "../../components/Skeleton"; import { StatusIcon, type StatusKind } from "../../components/StatusIcon"; import type { CoreConnectionState } from "../../lib/connection"; import { useThreadScroll } from "../../lib/use-thread-scroll"; import { knownSessionAdmissionBlocker } from "../agents/session-admission"; +import { + SessionStartDialog, + type SessionStartInput, +} from "./create/SessionStartDialog"; import { EnvironmentConnectionNotice, - EnvironmentPanel, + resolveEnvironmentPresentation, } from "./environment/EnvironmentPanel"; +import { EnvironmentDialog } from "./environment/EnvironmentDialog"; import type { EnvironmentObservation } from "./environment/environment-state"; import { ThreadItems } from "./items/ItemRenderers"; +import { + beginLocalPendingMessage, + hasDurablePendingMessage, + type LocalPendingMessage, +} from "./pending-message"; import { TraceView } from "./trace/TraceView"; import type { TurnTimelineLoadState } from "./turns/TurnTimeline"; import { SessionActionsDialog } from "./actions/SessionActionsDialog"; @@ -46,6 +56,11 @@ export type StreamState = "idle" | "connecting" | "listening" | "recovering" | " export type SessionDetailState = "idle" | "loading" | "ready" | "failed"; type SessionView = "conversation" | "trace"; +export interface SessionCreateRequest { + agentId: string | null; + requestId: number; +} + interface SessionsViewProps { agents: SavedAgent[]; sessions: AgentSession[]; @@ -55,7 +70,7 @@ interface SessionsViewProps { busy: boolean; coreError: string | null; coreState: CoreConnectionState; - createRequest?: number; + createRequest?: SessionCreateRequest | null; onCreateRequestConsumed?: (request: number) => void; detailError: string | null; detailState: SessionDetailState; @@ -65,8 +80,9 @@ interface SessionsViewProps { sendError?: FailedPendingSend | null; streamError: string | null; streamState: StreamState; + selfHostedEnabled?: boolean; onCancel: () => Promise; - onCreateSession: (agentId: string) => Promise; + onCreateSession: (input: SessionStartInput) => Promise; onDeleteSession: (sessionId: string) => Promise; onFunctionResult: (input: FunctionResultInput) => Promise; onRefresh: () => void; @@ -82,7 +98,7 @@ interface SessionsViewProps { ) => Promise; } -const coreRuntimeSetupUrl = "https://github.com/MiniMax-AI-Dev/parsar/blob/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee/services/agents-api/README.md#public-text-execution"; +const coreRuntimeSetupUrl = "https://github.com/MiniMax-AI-Dev/parsar/blob/2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e/services/agents-api/README.md#public-text-execution"; function SessionsListSkeleton() { return ( @@ -166,6 +182,14 @@ function streamStatusKind(state: StreamState): StatusKind { return "queued"; } +function streamStatusLabel(state: StreamState): string { + if (state === "connecting") return "Connecting events…"; + if (state === "listening") return "Live events"; + if (state === "recovering") return "Reconnecting events…"; + if (state === "failed") return "Events unavailable"; + return "Events idle"; +} + function isEnvironmentConnectionAction(value: unknown): value is EnvironmentConnectionAction { if (value === null || typeof value !== "object" || Array.isArray(value)) return false; const action = value as Record; @@ -218,11 +242,22 @@ function CancelOnlyBar({ busy, onCancel }: { busy: boolean; onCancel: () => void ); } -function ConversationActivity({ agentName }: { agentName: string }) { +function PendingUserMessage({ message }: { message: LocalPendingMessage }) { + return ( +
+
+
{message.payload}
+ Sending… +
+
+ ); +} + +function ConversationActivity({ label }: { label: string }) { return (
- {agentName} is working… + {label}
); } @@ -311,7 +346,7 @@ export function SessionsView({ busy, coreError, coreState, - createRequest = 0, + createRequest = null, onCreateRequestConsumed, detailError, detailState, @@ -321,6 +356,7 @@ export function SessionsView({ sendError = null, streamError, streamState, + selfHostedEnabled = false, onCancel, onCreateSession, onDeleteSession, @@ -342,10 +378,12 @@ export function SessionsView({ const [message, setMessage] = useState(""); const [sessionView, setSessionView] = useState("conversation"); const [newSessionOpen, setNewSessionOpen] = useState(false); - const [agentId, setAgentId] = useState(firstStartableAgent?.id ?? ""); + const [environmentDialogSessionId, setEnvironmentDialogSessionId] = useState(null); + const [preselectedAgentId, setPreselectedAgentId] = useState(null); const [actionSession, setActionSession] = useState(null); const [viewport, setViewport] = useState(null); const [threadContent, setThreadContent] = useState(null); + const [pendingMessage, setPendingMessage] = useState(null); const sendingRef = useRef(false); const pageRef = useRef(null); const newSessionActionRef = useRef(null); @@ -361,20 +399,12 @@ export function SessionsView({ threadContent, ); - const selectedAgent = agents.find((agent) => agent.id === agentId); - const selectedAgentBlocker = selectedAgent ? knownSessionAdmissionBlocker(selectedAgent) : null; - useEffect(() => { - if (selectedAgent && !selectedAgentBlocker) return; - const next = agents.find((agent) => !knownSessionAdmissionBlocker(agent)); - if ((next?.id ?? "") !== agentId) setAgentId(next?.id ?? ""); - }, [agentId, agents, selectedAgent, selectedAgentBlocker]); - - useEffect(() => { - if (!createRequest || createRequest === lastCreateRequestRef.current) return; - lastCreateRequestRef.current = createRequest; + if (!createRequest || createRequest.requestId === lastCreateRequestRef.current) return; + lastCreateRequestRef.current = createRequest.requestId; + setPreselectedAgentId(createRequest.agentId); setNewSessionOpen(true); - onCreateRequestConsumed?.(createRequest); + onCreateRequestConsumed?.(createRequest.requestId); }, [createRequest, onCreateRequestConsumed]); useEffect(() => { @@ -409,14 +439,31 @@ export function SessionsView({ setMessage((current) => restoreDraftAfterFailedSend(current, sendError.payload)); }, [selected?.id, sendError]); + const visiblePendingMessage = pendingMessage && + pendingMessage.sessionId === selected?.id && + !hasDurablePendingMessage(pendingMessage, items) + ? pendingMessage + : null; + + useEffect(() => { + if ( + !pendingMessage || + pendingMessage.sessionId !== selected?.id || + !hasDurablePendingMessage(pendingMessage, items) + ) return; + setPendingMessage((current) => current === pendingMessage ? null : current); + }, [items, pendingMessage, selected?.id]); + const send = async (event?: FormEvent) => { event?.preventDefault(); const value = message.trim(); if (sendingRef.current || busy || !value || !selected || detailState !== "ready" || streamState !== "listening") return; const sessionId = selected.id; + const pending = beginLocalPendingMessage(sessionId, value, items); sendingRef.current = true; draftsBySessionRef.current.set(sessionId, ""); setMessage(""); + setPendingMessage(pending); try { await onSend(value); if (selectedIdRef.current === sessionId) scrollToLatest(); @@ -434,6 +481,7 @@ export function SessionsView({ }); } } finally { + setPendingMessage((current) => current === pending ? null : current); sendingRef.current = false; } }; @@ -446,16 +494,6 @@ export function SessionsView({ } }; - const createSession = async () => { - if (coreState !== "ready" || !agentId || selectedAgentBlocker) return; - try { - await onCreateSession(agentId); - } catch { - return; - } - setNewSessionOpen(false); - }; - const cancel = () => { void onCancel().catch(() => undefined); }; @@ -472,6 +510,9 @@ export function SessionsView({ (selected?.status === "in_progress" || selected?.status === "requires_action") && (unsupportedActionCount > 0 || environmentConnections.length > 0 && functionActions.length === 0), ); + const environmentPresentation = selected + ? resolveEnvironmentPresentation(selected.environment, environmentObservation, environmentConnections) + : null; const onViewTabKeyDown = (event: KeyboardEvent) => { const tabs = Array.from(event.currentTarget.parentElement?.querySelectorAll("[role=tab]") ?? []); @@ -507,7 +548,10 @@ export function SessionsView({ className="icon-button primary session-create-trigger" type="button" onClick={() => { - if (!newSessionUnavailableReason) setNewSessionOpen(true); + if (!newSessionUnavailableReason) { + setPreselectedAgentId(null); + setNewSessionOpen(true); + } }} disabled={coreState !== "ready"} aria-disabled={newSessionUnavailableReason ? true : undefined} @@ -603,23 +647,56 @@ export function SessionsView({

{selected.agent.name || "Untitled Agent"} · {selected.agent.model}

-
- - {streamState} +
+ {environmentPresentation?.visible ? ( + + ) : null} +
+ + {streamStatusLabel(streamState)} +
+
- + {environmentPresentation?.visible ? ( + setEnvironmentDialogSessionId(null)} + environment={selected.environment} + observation={environmentObservation} + connectionActions={environmentConnections} + defaultLauncherGuideOpen={environmentPresentation.defaultLauncherGuideOpen} + /> + ) : null} +
- -
+ {visiblePendingMessage ? : null}
{detailState === "loading" && !items.length ? : null} @@ -730,7 +802,7 @@ export function SessionsView({ /> ) : null} - {detailState === "ready" && streamState !== "failed" && selected.status !== "failed" && !items.length ? ( + {detailState === "ready" && streamState !== "failed" && selected.status !== "failed" && !items.length && !visiblePendingMessage ? (

Session is ready

@@ -753,11 +825,17 @@ export function SessionsView({
{environmentConnections.map((action, index) => ( - + setEnvironmentDialogSessionId(selected.id) : undefined} + /> ))} {unsupportedActionCount ? : null} {selected.status === "in_progress" ? ( - + + ) : visiblePendingMessage ? ( + ) : null} {showCancelOnly ? ( @@ -841,43 +919,15 @@ export function SessionsView({
)} - setNewSessionOpen(false)} - title="Start an idle Session" - footer={ - <> - - - - } - > - - {!firstStartableAgent ? ( -
- No loaded Agent matches the known Core Session-admission profile. Create an Agent with the Web defaults or update the saved configuration first. -
- ) : null} -
- The Session starts idle so the UI can subscribe before the first Turn. -
-
+ onSubmit={onCreateSession} + /> = {}): SavedAgent { + return { + id, + object: "agent", + model: "provider/model", + name, + instructions: null, + metadata: {}, + multi_agent: { enabled: false, max_concurrent_subagents: null }, + reasoning: {}, + service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + tools: [], + created_at: 1, + updated_at: 1, + ...overrides, + }; +} + +const compatible = agent("agent_compatible", "Compatible Agent"); +const blocked = agent("agent_blocked", "Blocked Agent", { + reasoning: { effort: "high" }, +}); + +function render(selfHostedEnabled: boolean, preselectedAgentId?: string) { + return renderToStaticMarkup( + undefined} + onSubmit={async () => undefined} + />, + ); +} + +describe("SessionStartDialog", () => { + it("does not mount dialog content while closed", () => { + const html = renderToStaticMarkup( + undefined} + onSubmit={async () => undefined} + />, + ); + expect(html).toBe(""); + }); + + it("keeps self-hosted creation hidden by default and selects environment:none", () => { + const html = render(false); + expect(html).toContain("Start an idle Session"); + expect(html).toContain("Execution environment"); + expect(html).toContain("No environment"); + expect(html).toMatch(/]+checked=""[^>]+value="none"/); + expect(html).not.toContain("Self-hosted"); + expect(html).not.toContain("Workspace directory"); + expect(html).not.toContain("Environment key { + const html = render(true); + expect(html).toContain('value="self_hosted"'); + expect(html).toContain("operator-managed Linux executor"); + expect(html).not.toContain('name="environment_key"'); + expect(html).not.toContain('type="password"'); + expect(html).not.toContain("executor_token"); + }); + + it("honors a compatible preselected Agent and disables blocked options", () => { + const html = render(true, compatible.id); + expect(html).toContain('
- - {statusLabel(status)} + + {presentation.statusLabel}
@@ -176,6 +366,17 @@ export function EnvironmentPanel({ : "Connection status is unknown because the durable Session projection does not expose it."}

+ {status !== "connected" && status !== "ready" ? ( + + ) : null} + {status === "failed" ? (
Environment failed diff --git a/apps/web/src/features/sessions/environment/environment-launcher.test.ts b/apps/web/src/features/sessions/environment/environment-launcher.test.ts new file mode 100644 index 0000000..757a04c --- /dev/null +++ b/apps/web/src/features/sessions/environment/environment-launcher.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import type { LocalDockerGuideProfile } from "../../../lib/docker-guide-config"; +import { buildLauncherCommand, buildLocalDockerCommand } from "./environment-launcher"; + +const environmentId = "0f745b0d-b545-49cd-8d7e-4c31c80dc564"; +const dockerProfile: LocalDockerGuideProfile = { + image: "agents-core-web-executor:2b34ea46-codex-0.153.4", + apiContainer: "agents-core-web-api", + user: "501:20", + credentialsHomePath: ".parsar/agents-api-web-smoke/executor-key.json", + runtimeHomePath: ".parsar/agents-api-web-smoke/executors", +}; + +describe("Environment launcher commands", () => { + it("builds the native launcher only from the strict supported projection", () => { + const valid = (remoteUrl: string, id = environmentId) => ( + buildLauncherCommand(id, remoteUrl, "/executor/workspace", []) + ); + const command = valid("https://executor.example.test"); + expect(command).toContain(`REMOTE_URL='https://executor.example.test'`); + expect(command).toContain(`ENVIRONMENT_ID='${environmentId}'`); + expect(command).toContain('agents-api-codex-executor \\\n --remote "$REMOTE_URL"'); + expect(command).toContain('--credentials "$HOME/.parsar/executor-key.json"'); + expect(command).not.toContain("token"); + + expect(valid("http://127.0.0.1:8091/")).not.toBeNull(); + expect(valid("http://executor.example.test")).toBeNull(); + expect(valid("https://user:secret@executor.example.test")).toBeNull(); + expect(valid("https://executor.example.test/path")).toBeNull(); + expect(valid("https://executor.example.test?token=secret")).toBeNull(); + expect(valid("https://executor.example.test", environmentId.toUpperCase())).toBeNull(); + expect(valid("https://executor.example.test", "00000000-0000-0000-0000-000000000000")).toBeNull(); + expect(buildLauncherCommand(environmentId, "https://executor.example.test", null, [])).toBeNull(); + expect(buildLauncherCommand(environmentId, "https://executor.example.test", "relative", [])).toBeNull(); + expect(buildLauncherCommand(environmentId, "https://executor.example.test", "/workspace", null)).toBeNull(); + expect(buildLauncherCommand(environmentId, "https://executor.example.test", "/workspace", ["/capability"])).toBeNull(); + }); + + it("builds a ready-to-copy local Docker command for a loopback Core", () => { + const command = buildLocalDockerCommand( + environmentId, + "http://127.0.0.1:8091", + "/good", + [], + dockerProfile, + ); + expect(command).toContain("docker run --detach"); + expect(command).toContain(`ENVIRONMENT_ID='${environmentId}'`); + expect(command).toContain("WORKSPACE_DIRECTORY='/good'"); + expect(command).toContain("HOST_WORKSPACE_DIRECTORY=\"${HOST_WORKSPACE_DIRECTORY:-$PWD}\""); + expect(command).toContain('--network "container:$API_CONTAINER"'); + expect(command).toContain('--workdir "$WORKSPACE_DIRECTORY"'); + expect(command).toContain('src=$HOST_WORKSPACE_DIRECTORY,dst=$WORKSPACE_DIRECTORY'); + expect(command).toContain("agents-core-web-executor-$ENVIRONMENT_ID"); + expect(command).toContain(".parsar/agents-api-web-smoke/executor-key.json"); + expect(command).not.toContain("executor_token"); + expect(command).not.toContain("Authorization"); + expect(command).not.toContain("--rm"); + expect(command).not.toContain("--privileged"); + }); + + it("keeps the Docker recipe local, opt-in, and fail-closed", () => { + expect(buildLocalDockerCommand(environmentId, "http://127.0.0.1:8091", "/good", [], null)).toBeNull(); + expect(buildLocalDockerCommand(environmentId, "https://executor.example.test", "/good", [], dockerProfile)).toBeNull(); + expect(buildLocalDockerCommand(environmentId, "http://127.0.0.1:8091", "/bad,path", [], dockerProfile)).toBeNull(); + expect(buildLocalDockerCommand(environmentId, "http://127.0.0.1:8091", "/bad:path", [], dockerProfile)).toBeNull(); + expect(buildLocalDockerCommand(environmentId, "http://127.0.0.1:8091", "/good", ["/capability"], dockerProfile)).toBeNull(); + expect(buildLocalDockerCommand(environmentId, "http://127.0.0.1:8091", "/good", [], { + ...dockerProfile, + image: "executor; touch /tmp/pwned", + })).toBeNull(); + expect(buildLocalDockerCommand(environmentId, "http://127.0.0.1:8091", "/good", [], { + ...dockerProfile, + credentialsHomePath: "../executor-key.json", + })).toBeNull(); + }); +}); diff --git a/apps/web/src/features/sessions/environment/environment-launcher.ts b/apps/web/src/features/sessions/environment/environment-launcher.ts new file mode 100644 index 0000000..63dbb96 --- /dev/null +++ b/apps/web/src/features/sessions/environment/environment-launcher.ts @@ -0,0 +1,163 @@ +import type { LocalDockerGuideProfile } from "../../../lib/docker-guide-config"; +import { validateWorkspaceDirectory } from "../create/session-environment"; + +const canonicalEnvironmentIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const dockerImagePattern = /^[A-Za-z0-9][A-Za-z0-9._/:@-]*$/; +const dockerContainerPattern = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; +const dockerUserPattern = /^[1-9][0-9]*:[1-9][0-9]*$/; +const homePathSegmentPattern = /^[A-Za-z0-9._-]+$/; + +interface ConnectionProjection { + environmentId: string; + remoteUrl: string; + remote: URL; + workspaceDirectory: string; +} + +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + if (normalized === "localhost" || normalized === "[::1]" || normalized === "::1") return true; + const match = normalized.match(/^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + return Boolean(match && match.slice(1).every((part) => Number(part) <= 255)); +} + +function connectionProjection( + environmentId: unknown, + remoteValue: unknown, + workspaceDirectory: unknown, + capabilityDirectories: unknown, +): ConnectionProjection | null { + if ( + typeof environmentId !== "string" || + !canonicalEnvironmentIdPattern.test(environmentId) || + environmentId === "00000000-0000-0000-0000-000000000000" || + typeof remoteValue !== "string" || + !remoteValue || + remoteValue.trim() !== remoteValue || + typeof workspaceDirectory !== "string" || + validateWorkspaceDirectory(workspaceDirectory) !== null || + !Array.isArray(capabilityDirectories) || + capabilityDirectories.length !== 0 + ) return null; + + try { + const remote = new URL(remoteValue); + const pathnameIsOrigin = remote.pathname === "" || remote.pathname === "/"; + const supportedProtocol = remote.protocol === "https:" || remote.protocol === "http:" && isLoopbackHostname(remote.hostname); + if ( + !supportedProtocol || + !remote.hostname || + remote.username || + remote.password || + remote.search || + remote.hash || + !pathnameIsOrigin || + (remoteValue !== remote.origin && remoteValue !== `${remote.origin}/`) + ) return null; + return { environmentId, remoteUrl: remoteValue, remote, workspaceDirectory }; + } catch { + return null; + } +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function validHomeRelativePath(value: string): boolean { + if (!value || value.startsWith("/") || value.endsWith("/") || value.includes("//")) return false; + return value.split("/").every((segment) => ( + segment !== "." && segment !== ".." && homePathSegmentPattern.test(segment) + )); +} + +function validDockerProfile(profile: LocalDockerGuideProfile): boolean { + return dockerImagePattern.test(profile.image) && + dockerContainerPattern.test(profile.apiContainer) && + dockerUserPattern.test(profile.user) && + validHomeRelativePath(profile.credentialsHomePath) && + validHomeRelativePath(profile.runtimeHomePath); +} + +export function buildLauncherCommand( + environmentId: unknown, + remoteValue: unknown, + workspaceDirectory: unknown, + capabilityDirectories: unknown, +): string | null { + const projection = connectionProjection( + environmentId, + remoteValue, + workspaceDirectory, + capabilityDirectories, + ); + if (!projection) return null; + + return [ + `REMOTE_URL=${shellQuote(projection.remoteUrl)}`, + `ENVIRONMENT_ID=${shellQuote(projection.environmentId)}`, + "agents-api-codex-executor \\", + " --remote \"$REMOTE_URL\" \\", + " --environment-id \"$ENVIRONMENT_ID\" \\", + " --credentials \"$HOME/.parsar/executor-key.json\" \\", + " --codex-bin /opt/codex/bin/codex", + ].join("\n"); +} + +export function buildLocalDockerCommand( + environmentId: unknown, + remoteValue: unknown, + workspaceDirectory: unknown, + capabilityDirectories: unknown, + profile: LocalDockerGuideProfile | null, +): string | null { + const projection = connectionProjection( + environmentId, + remoteValue, + workspaceDirectory, + capabilityDirectories, + ); + if ( + !profile || + !projection || + !validDockerProfile(profile) || + projection.remote.protocol !== "http:" || + !isLoopbackHostname(projection.remote.hostname) || + projection.workspaceDirectory.includes(":") || + projection.workspaceDirectory.includes(",") + ) return null; + + return [ + `REMOTE_URL=${shellQuote(projection.remoteUrl)}`, + `ENVIRONMENT_ID=${shellQuote(projection.environmentId)}`, + `WORKSPACE_DIRECTORY=${shellQuote(projection.workspaceDirectory)}`, + `EXECUTOR_IMAGE=${shellQuote(profile.image)}`, + `API_CONTAINER=${shellQuote(profile.apiContainer)}`, + `EXECUTOR_USER=${shellQuote(profile.user)}`, + `EXECUTOR_KEY_FILE=\"$HOME/${profile.credentialsHomePath}\"`, + `EXECUTOR_ROOT=\"$HOME/${profile.runtimeHomePath}/$ENVIRONMENT_ID\"`, + "STATE_DIRECTORY=\"$EXECUTOR_ROOT/state\"", + "HOST_WORKSPACE_DIRECTORY=\"${HOST_WORKSPACE_DIRECTORY:-$PWD}\"", + "test -f \"$EXECUTOR_KEY_FILE\" || { echo \"Executor credential file not found: $EXECUTOR_KEY_FILE\" >&2; exit 1; }", + "test -d \"$HOST_WORKSPACE_DIRECTORY\" || { echo \"Host Workspace directory not found: $HOST_WORKSPACE_DIRECTORY\" >&2; exit 1; }", + "mkdir -p \"$STATE_DIRECTORY\"", + "chmod 700 \"$STATE_DIRECTORY\"", + "docker run --detach \\", + " --name \"agents-core-web-executor-$ENVIRONMENT_ID\" \\", + " --platform linux/amd64 \\", + " --network \"container:$API_CONTAINER\" \\", + " --user \"$EXECUTOR_USER\" \\", + " --workdir \"$WORKSPACE_DIRECTORY\" \\", + " --cap-drop ALL \\", + " --security-opt no-new-privileges \\", + " --restart no \\", + " --mount \"type=bind,src=$EXECUTOR_KEY_FILE,dst=/run/executor-key.json,readonly\" \\", + " --mount \"type=bind,src=$STATE_DIRECTORY,dst=/executor/.parsar\" \\", + " --mount \"type=bind,src=$HOST_WORKSPACE_DIRECTORY,dst=$WORKSPACE_DIRECTORY\" \\", + " \"$EXECUTOR_IMAGE\" \\", + " --remote \"$REMOTE_URL\" \\", + " --environment-id \"$ENVIRONMENT_ID\" \\", + " --credentials /run/executor-key.json \\", + " --codex-bin /opt/codex/bin/codex", + ].join("\n"); +} diff --git a/apps/web/src/features/sessions/pending-message.test.ts b/apps/web/src/features/sessions/pending-message.test.ts new file mode 100644 index 0000000..3824ab6 --- /dev/null +++ b/apps/web/src/features/sessions/pending-message.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import type { SessionItem } from "@agents-core-web/agents-client"; + +import { beginLocalPendingMessage, hasDurablePendingMessage } from "./pending-message"; + +function message(id: string, role: "user" | "assistant", text: string): SessionItem { + return { + id, + turn_id: `turn-${id}`, + type: "message", + status: "completed", + role, + content: [{ type: role === "user" ? "input_text" : "output_text", text }], + }; +} + +describe("local pending message projection", () => { + it("waits for a new matching durable user Item", () => { + const existing = message("item-existing", "user", "same text"); + const pending = beginLocalPendingMessage("session-1", "same text", [existing]); + + expect(hasDurablePendingMessage(pending, [existing])).toBe(false); + expect(hasDurablePendingMessage(pending, [ + existing, + message("item-assistant", "assistant", "same text"), + message("item-different", "user", "different text"), + ])).toBe(false); + expect(hasDurablePendingMessage(pending, [ + existing, + message("item-confirmed", "user", "same text"), + ])).toBe(true); + }); + + it("matches split durable input text using the rendered newline projection", () => { + const pending = beginLocalPendingMessage("session-1", "first\nsecond", []); + const durable: SessionItem = { + id: "item-confirmed", + turn_id: "turn-confirmed", + type: "message", + status: "completed", + role: "user", + content: [ + { type: "input_text", text: "first" }, + { type: "input_text", text: "second" }, + ], + }; + + expect(hasDurablePendingMessage(pending, [durable])).toBe(true); + }); +}); diff --git a/apps/web/src/features/sessions/pending-message.ts b/apps/web/src/features/sessions/pending-message.ts new file mode 100644 index 0000000..ea0dd61 --- /dev/null +++ b/apps/web/src/features/sessions/pending-message.ts @@ -0,0 +1,42 @@ +import type { SessionItem } from "@agents-core-web/agents-client"; + +export interface LocalPendingMessage { + sessionId: string; + payload: string; + baselineUserItemIds: readonly string[]; +} + +function messageText(item: SessionItem): string { + return (item.content ?? []) + .map((content) => content.text) + .filter((value): value is string => Boolean(value)) + .join("\n"); +} + +function isUserMessage(item: SessionItem): boolean { + return item.type === "message" && item.role === "user"; +} + +export function beginLocalPendingMessage( + sessionId: string, + payload: string, + items: SessionItem[], +): LocalPendingMessage { + return { + sessionId, + payload, + baselineUserItemIds: items.filter(isUserMessage).map((item) => item.id), + }; +} + +export function hasDurablePendingMessage( + pending: LocalPendingMessage, + items: SessionItem[], +): boolean { + const baseline = new Set(pending.baselineUserItemIds); + return items.some((item) => ( + isUserMessage(item) && + !baseline.has(item.id) && + messageText(item) === pending.payload + )); +} diff --git a/apps/web/src/lib/docker-guide-config.test.ts b/apps/web/src/lib/docker-guide-config.test.ts new file mode 100644 index 0000000..4177ef7 --- /dev/null +++ b/apps/web/src/lib/docker-guide-config.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { loadLocalDockerGuideProfile } from "./docker-guide-config"; + +const valid = { + AGENTS_CORE_WEB_DOCKER_GUIDE: "1", + AGENTS_CORE_WEB_DOCKER_IMAGE: "agents-core-web-executor:2b34ea46-codex-0.153.4", + AGENTS_CORE_WEB_DOCKER_API_CONTAINER: "agents-core-web-api", + AGENTS_CORE_WEB_DOCKER_USER: "501:20", + AGENTS_CORE_WEB_DOCKER_CREDENTIALS_HOME_PATH: ".parsar/agents-api-web-smoke/executor-key.json", + AGENTS_CORE_WEB_DOCKER_RUNTIME_HOME_PATH: ".parsar/agents-api-web-smoke/executors", +}; + +describe("local Docker guide configuration", () => { + it("is disabled unless the operator opts in exactly", () => { + expect(loadLocalDockerGuideProfile({})).toBeNull(); + expect(loadLocalDockerGuideProfile({ ...valid, AGENTS_CORE_WEB_DOCKER_GUIDE: "true" })).toBeNull(); + }); + + it("accepts a complete non-secret local profile", () => { + expect(loadLocalDockerGuideProfile(valid)).toEqual({ + image: valid.AGENTS_CORE_WEB_DOCKER_IMAGE, + apiContainer: valid.AGENTS_CORE_WEB_DOCKER_API_CONTAINER, + user: valid.AGENTS_CORE_WEB_DOCKER_USER, + credentialsHomePath: valid.AGENTS_CORE_WEB_DOCKER_CREDENTIALS_HOME_PATH, + runtimeHomePath: valid.AGENTS_CORE_WEB_DOCKER_RUNTIME_HOME_PATH, + }); + }); + + it("fails closed for partial or command-bearing values", () => { + expect(() => loadLocalDockerGuideProfile({ + ...valid, + AGENTS_CORE_WEB_DOCKER_IMAGE: "image; docker rm -f victim", + })).toThrow("safe Docker image reference"); + expect(() => loadLocalDockerGuideProfile({ + ...valid, + AGENTS_CORE_WEB_DOCKER_CREDENTIALS_HOME_PATH: "../executor-key.json", + })).toThrow("safe HOME-relative path"); + expect(() => loadLocalDockerGuideProfile({ + ...valid, + AGENTS_CORE_WEB_DOCKER_USER: "0:0", + })).toThrow("numeric non-root"); + + const partial: Record = { ...valid }; + delete partial.AGENTS_CORE_WEB_DOCKER_RUNTIME_HOME_PATH; + expect(() => loadLocalDockerGuideProfile(partial)).toThrow("AGENTS_CORE_WEB_DOCKER_RUNTIME_HOME_PATH is required"); + }); +}); diff --git a/apps/web/src/lib/docker-guide-config.ts b/apps/web/src/lib/docker-guide-config.ts new file mode 100644 index 0000000..10e7861 --- /dev/null +++ b/apps/web/src/lib/docker-guide-config.ts @@ -0,0 +1,58 @@ +export interface LocalDockerGuideProfile { + image: string; + apiContainer: string; + user: string; + credentialsHomePath: string; + runtimeHomePath: string; +} + +const dockerImagePattern = /^[A-Za-z0-9][A-Za-z0-9._/:@-]*$/; +const dockerContainerPattern = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; +const dockerUserPattern = /^[1-9][0-9]*:[1-9][0-9]*$/; +const homePathSegmentPattern = /^[A-Za-z0-9._-]+$/; + +function required(env: Record, name: string): string { + const value = env[name]; + if (!value) throw new Error(`${name} is required when AGENTS_CORE_WEB_DOCKER_GUIDE=1.`); + return value; +} + +function validHomeRelativePath(value: string): boolean { + if (value.startsWith("/") || value.endsWith("/") || value.includes("//")) return false; + const segments = value.split("/"); + return segments.length > 0 && segments.every((segment) => ( + segment !== "." && segment !== ".." && homePathSegmentPattern.test(segment) + )); +} + +export function loadLocalDockerGuideProfile( + env: Record, +): LocalDockerGuideProfile | null { + if (env.AGENTS_CORE_WEB_DOCKER_GUIDE !== "1") return null; + + const profile: LocalDockerGuideProfile = { + image: required(env, "AGENTS_CORE_WEB_DOCKER_IMAGE"), + apiContainer: required(env, "AGENTS_CORE_WEB_DOCKER_API_CONTAINER"), + user: required(env, "AGENTS_CORE_WEB_DOCKER_USER"), + credentialsHomePath: required(env, "AGENTS_CORE_WEB_DOCKER_CREDENTIALS_HOME_PATH"), + runtimeHomePath: required(env, "AGENTS_CORE_WEB_DOCKER_RUNTIME_HOME_PATH"), + }; + + if (!dockerImagePattern.test(profile.image)) { + throw new Error("AGENTS_CORE_WEB_DOCKER_IMAGE is not a safe Docker image reference."); + } + if (!dockerContainerPattern.test(profile.apiContainer)) { + throw new Error("AGENTS_CORE_WEB_DOCKER_API_CONTAINER is not a safe Docker container name."); + } + if (!dockerUserPattern.test(profile.user)) { + throw new Error("AGENTS_CORE_WEB_DOCKER_USER must be a numeric non-root uid:gid pair."); + } + if (!validHomeRelativePath(profile.credentialsHomePath)) { + throw new Error("AGENTS_CORE_WEB_DOCKER_CREDENTIALS_HOME_PATH must be a safe HOME-relative path."); + } + if (!validHomeRelativePath(profile.runtimeHomePath)) { + throw new Error("AGENTS_CORE_WEB_DOCKER_RUNTIME_HOME_PATH must be a safe HOME-relative path."); + } + + return profile; +} diff --git a/apps/web/src/style.css b/apps/web/src/style.css index eae767c..9c73744 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -1349,6 +1349,7 @@ html[data-theme="dark"] .brand-mark-dark { .conversation-heading-copy { min-width: 0; + flex: 1; } .conversation-title-row { @@ -2136,7 +2137,6 @@ html[data-theme="dark"] .brand-mark-dark { display: inline-flex; height: 24px; align-items: center; - margin-left: auto; padding: 0 7px; gap: 6px; color: var(--fg-muted); @@ -2146,6 +2146,70 @@ html[data-theme="dark"] .brand-mark-dark { white-space: nowrap; } +.conversation-header-actions { + display: flex; + min-width: 0; + flex: 0 0 auto; + align-items: center; + margin-left: auto; + gap: 4px; +} + +.environment-trigger { + display: inline-flex; + min-width: 0; + height: 28px; + align-items: center; + padding: 0 8px; + gap: 6px; + color: var(--fg-muted); + font: inherit; + font-size: 12px; + line-height: 16px; + white-space: nowrap; + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + cursor: pointer; +} + +.environment-trigger:hover, +.environment-trigger[aria-expanded="true"] { + color: var(--fg); + background: var(--surface-subtle); + border-color: var(--line); +} + +.environment-trigger:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.environment-trigger > span { + max-width: 176px; + overflow: hidden; + text-overflow: ellipsis; +} + +.environment-trigger > svg { + flex: 0 0 auto; + color: var(--status-interrupted); +} + +.environment-trigger-connected > svg, +.environment-trigger-ready > svg { + color: var(--success); +} + +.environment-trigger-pending > svg, +.environment-trigger-required > svg { + color: var(--warning); +} + +.environment-trigger-failed > svg { + color: var(--danger); +} + .conversation-session-action { width: 24px; height: 24px; @@ -2259,6 +2323,17 @@ html[data-theme="dark"] .brand-mark-dark { line-height: 16px; } +.environment-dialog .modal-card { + width: min(720px, calc(100vw - 40px)); +} + +.environment-dialog .environment-panel { + margin: 0; + padding: 0; + background: transparent; + border: 0; +} + .environment-panel-heading > div:first-of-type > span { overflow: hidden; font-family: var(--font-mono); @@ -2401,6 +2476,156 @@ html[data-theme="dark"] .brand-mark-dark { color: var(--warning); } +.environment-launcher-guide, +.environment-launcher-unavailable { + margin-top: 10px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; +} + +.environment-launcher-guide > summary { + display: flex; + min-height: 42px; + align-items: center; + padding: 8px 10px; + gap: 8px; + cursor: pointer; + list-style: none; +} + +.environment-launcher-guide > summary::-webkit-details-marker { + display: none; +} + +.environment-launcher-guide > summary::after { + margin-left: auto; + content: "›"; + color: var(--fg-muted); + font-size: 17px; + transform-origin: center; + transition: transform 120ms var(--ease-settle); +} + +.environment-launcher-guide[open] > summary::after { + transform: rotate(90deg); +} + +.environment-launcher-guide > summary > svg { + flex: 0 0 auto; + color: var(--accent); +} + +.environment-launcher-guide > summary > span { + display: flex; + min-width: 0; + flex-direction: column; +} + +.environment-launcher-guide > summary strong, +.environment-launcher-unavailable strong { + color: var(--fg); + font-size: 12px; + font-weight: 500; + line-height: 17px; +} + +.environment-launcher-guide > summary small { + color: var(--fg-muted); + font-size: 10px; + line-height: 14px; +} + +.environment-launcher-body { + display: flex; + min-width: 0; + flex-direction: column; + align-items: flex-start; + padding: 10px; + gap: 9px; + border-top: 1px solid var(--line); +} + +.environment-launcher-body > p, +.environment-launcher-unavailable p { + margin: 0; + color: var(--fg-muted); + font-size: 11px; + line-height: 16px; +} + +.environment-launcher-modes { + display: inline-flex; + padding: 2px; + gap: 2px; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 5px; +} + +.environment-launcher-modes button { + min-height: 26px; + padding: 4px 9px; + color: var(--fg-muted); + font: inherit; + font-size: 11px; + background: transparent; + border: 0; + border-radius: 3px; + cursor: pointer; +} + +.environment-launcher-modes button[aria-pressed="true"] { + color: var(--fg); + background: var(--surface); + box-shadow: 0 0 0 1px var(--line); +} + +.environment-launcher-modes button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.environment-launcher-body > p code { + color: var(--fg); + font-family: var(--font-mono); +} + +.environment-launcher-body pre { + width: 100%; + max-width: 100%; + margin: 0; + padding: 9px 10px; + overflow-x: auto; + color: var(--fg); + font-family: var(--font-mono); + font-size: 11px; + line-height: 17px; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 5px; +} + +.environment-launcher-body .button { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.environment-launcher-security { + padding-left: 9px; + border-left: 2px solid var(--warning); +} + +.environment-launcher-unavailable { + padding: 9px 10px; + border-left: 2px solid var(--warning); +} + +.environment-launcher-unavailable p { + margin-top: 2px; +} + .environment-panel-footer { display: flex; align-items: flex-end; @@ -3053,6 +3278,17 @@ html[data-theme="dark"] .brand-mark-dark { border-radius: 6px; } +.message-row.pending-message .message-copy { + opacity: 0.82; +} + +.message-delivery-state { + margin-top: 4px; + color: var(--fg-muted); + font-size: 11px; + line-height: 15px; +} + .work-trace { width: 100%; min-width: 0; @@ -3459,6 +3695,13 @@ button.trace-step-row:hover { overflow-wrap: anywhere; } +.environment-connection-notice-action { + min-height: 28px; + margin-top: 8px; + padding: 4px 9px; + font-size: 11px; +} + .approval-bar { display: flex; width: 100%; @@ -3808,6 +4051,70 @@ button.trace-step-row:hover { line-height: 16px; } +.session-environment-options { + display: grid; + min-width: 0; + margin: 0; + padding: 0; + gap: 7px; + border: 0; +} + +.session-environment-options legend { + margin-bottom: 4px; + color: var(--fg-muted); + font-size: 12px; + line-height: 16px; +} + +.session-environment-option { + display: flex; + min-width: 0; + align-items: flex-start; + padding: 9px 10px; + gap: 9px; + cursor: pointer; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 6px; +} + +.session-environment-option:has(input:checked) { + background: color-mix(in srgb, var(--accent) 7%, var(--surface)); + border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); +} + +.session-environment-option:focus-within { + outline: 2px solid color-mix(in srgb, var(--accent) 38%, transparent); + outline-offset: 1px; +} + +.session-environment-option input { + flex: 0 0 auto; + margin: 2px 0 0; + accent-color: var(--accent); +} + +.session-environment-option > span { + display: flex; + min-width: 0; + flex-direction: column; + gap: 1px; +} + +.session-environment-option strong { + color: var(--fg); + font-size: 12px; + font-weight: 500; + line-height: 17px; +} + +.session-environment-option small { + color: var(--fg-muted); + font-size: 11px; + line-height: 15px; +} + .model-picker-note { display: flex; align-items: flex-start; @@ -4101,6 +4408,11 @@ button.trace-step-row:hover { padding: 8px; } + .environment-dialog .modal-backdrop { + align-items: flex-end; + padding: 8px; + } + .agent-dialog .modal-card { width: calc(100vw - 16px); max-height: calc(100vh - 16px); @@ -4111,6 +4423,27 @@ button.trace-step-row:hover { max-height: calc(100vh - 16px); } + .environment-dialog .modal-card { + width: calc(100vw - 16px); + max-height: calc(100vh - 16px); + } + + .conversation-header-actions { + gap: 2px; + } + + .environment-trigger, + .stream-indicator { + width: 28px; + justify-content: center; + padding: 0; + } + + .environment-trigger > span, + .stream-indicator > span { + display: none; + } + .agent-details dl > div, .session-details dl > div { grid-template-columns: 1fr; @@ -4218,21 +4551,26 @@ button.trace-step-row:hover { padding-left: 0; } - .session-page .environment-panel-grid { + .session-page .environment-panel-grid, + .environment-dialog .environment-panel-grid { grid-template-columns: 1fr; } - .session-page .environment-panel-field-wide { + .session-page .environment-panel-field-wide, + .environment-dialog .environment-panel-field-wide { grid-column: auto; } .session-page .environment-panel-footer, - .session-page .environment-panel-unavailable { + .session-page .environment-panel-unavailable, + .environment-dialog .environment-panel-footer, + .environment-dialog .environment-panel-unavailable { align-items: flex-start; flex-direction: column; } - .session-page .environment-panel-unavailable > p { + .session-page .environment-panel-unavailable > p, + .environment-dialog .environment-panel-unavailable > p { text-align: left; } @@ -4995,6 +5333,22 @@ button.trace-step-row:hover { .agent-form-grid { grid-template-columns: 1fr; } + + .conversation-header-actions { + gap: 2px; + } + + .environment-trigger, + .stream-indicator { + width: 28px; + justify-content: center; + padding: 0; + } + + .environment-trigger > span, + .stream-indicator > span { + display: none; + } } @media (max-width: 640px) { diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index bd99e8e..1ca8229 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -1,6 +1,14 @@ /// declare const __AGENTS_CORE_WEB_DEV_PROXY_AUTH__: boolean; +declare const __AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS__: boolean; +declare const __AGENTS_CORE_WEB_DOCKER_GUIDE__: null | { + readonly image: string; + readonly apiContainer: string; + readonly user: string; + readonly credentialsHomePath: string; + readonly runtimeHomePath: string; +}; interface ImportMetaEnv { readonly VITE_AGENT_MODEL_PRESETS?: string; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 1c82f73..e92b33b 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -4,6 +4,7 @@ import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react"; import { fileURLToPath } from "node:url"; +import { loadLocalDockerGuideProfile } from "./src/lib/docker-guide-config.ts"; import { loadProxyBearerAuth } from "./vite-auth.ts"; const repositoryRoot = fileURLToPath(new URL("../..", import.meta.url)); @@ -11,6 +12,8 @@ const repositoryRoot = fileURLToPath(new URL("../..", import.meta.url)); export default defineConfig(({ command, mode }) => { const env = loadEnv(mode, repositoryRoot, ""); const target = env.AGENTS_API_PROXY_TARGET ?? "http://127.0.0.1:8091"; + const selfHostedSessionsEnabled = env.AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS === "1"; + const localDockerGuide = loadLocalDockerGuideProfile(env); const proxyAuth = command === "serve" && mode !== "test" ? loadProxyBearerAuth({ token: env.AGENTS_API_PROXY_TOKEN, @@ -22,6 +25,8 @@ export default defineConfig(({ command, mode }) => { return { define: { __AGENTS_CORE_WEB_DEV_PROXY_AUTH__: JSON.stringify(Boolean(proxyAuth)), + __AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS__: JSON.stringify(selfHostedSessionsEnabled), + __AGENTS_CORE_WEB_DOCKER_GUIDE__: JSON.stringify(localDockerGuide), }, envDir: repositoryRoot, plugins: [react()], diff --git a/docs/architecture.md b/docs/architecture.md index b7a3fa5..46878a8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,7 +18,7 @@ This boundary is intentional: simulated in Web. The current compatibility audit baseline is Parsar -[`d91ba48a`](https://github.com/MiniMax-AI-Dev/parsar/commit/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee), +[`2b34ea46`](https://github.com/MiniMax-AI-Dev/parsar/commit/2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e), whose contract is pinned to `openai-python` 3.13.0 commit [`d7c41efe`](https://github.com/openai/openai-python/tree/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents). This is a fixed beta subset, not a claim that every current OpenAI Agents API @@ -52,10 +52,14 @@ flowchart LR end compatible["Alternative compatible Core"] + executor["Caller-managed Linux executor
agents-api-codex-executor"] + workspace["Executor-host Workspace"] native["Model provider / MCP / tools"] user --> ui boundary -->|"HTTP JSON + SSE stream
Bearer + agents=v1"| core client -. "direct CORS URL
tab-scoped bearer" .-> compatible + executor -->|"native registration + opaque relay
separate executor credential"| core + executor --> workspace adapter --> native ``` @@ -73,6 +77,7 @@ that is a separate product layer. | Agent Core | principal authentication, validation, idempotency, durable Agent/Session/Turn/Item state, live events, scheduling | product organization UI or Web user sessions | | `parsar-daemon` | device connection, host capability advertisement, native process lifecycle and translation | public Agents HTTP semantics or product policy | | Native adapter | Codex app-server or Claude Agent SDK integration | public API and Web deployment policy | +| Self-hosted executor | native Codex command/file execution inside its process-user and sandbox boundary | browser UI, Core caller authentication, provider/model credentials | | Runtime/environment provider | allocation, attach, lease, recovery, cancellation, cleanup | conversation-resource semantics | Docker, E2B, and AWS Bedrock AgentCore Runtime are possible core/runtime @@ -87,15 +92,18 @@ capability and its lifecycle behavior is verified. | Browser → proxy/BFF | Same-origin HTTP under `/v1` | Web deployment policy; local proxy holds a Core bearer | Agents Core Web deployment | | Proxy/BFF → Core | HTTP JSON and SSE passthrough under `/v1/agents/**` | `Authorization: Bearer …`; `OpenAI-Beta: agents=v1` | Pinned Agents API subset | | Core ↔ daemon | Private reverse WebSocket, Parsar JSON envelope protocol | Separate device credential | Parsar internal protocol | +| Core ↔ self-hosted executor | Native registration plus opaque relay outside `/v1/agents/**` | Operator-issued executor principal credential | Parsar native executor contract | | Daemon ↔ Codex | `codex app-server --stdio`; JSON-RPC 2.0 over newline-delimited JSON | Native host configuration | Codex adapter | | Daemon ↔ Claude | Packaged Claude Agent SDK bridge | Native host configuration | Claude adapter | -| Harness ↔ model/tools | Provider-native APIs, MCP, and tool protocols | Provider credential on executor host | Selected harness/provider | +| Harness ↔ model/tools | Provider-native APIs, MCP, and tool protocols | Provider credential on native harness host | Selected harness/provider | These interfaces are not interchangeable. In particular: - the daemon WebSocket URL is not an Agents API base URL; - OpenAI Agents API is not the same thing as OpenAI Agents SDK or Responses API; - saving a model ID does not select an executor or prove provider availability; +- a Session Environment ID, connection state, or copied launcher command does not + prove native readiness, isolation, or completed execution; - multiple saved Agents do not imply protocol multi-agent/Subagent support. OpenAI's official [Agents guide](https://developers.openai.com/api/docs/guides/agents) @@ -132,6 +140,22 @@ Creating an idle Session before sending the first message lets the browser open live stream before work starts. A successful submission means Core admitted the event; it is not by itself proof that a native Turn completed. +The default Session uses `environment:none`. With the default-off +`AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS=1` operator flag, the same create boundary can +instead request a Codex `self_hosted` Environment with an absolute executor-host +Workspace and empty capability directories. Core returns its Session-scoped +Environment ID and executor origin. Web may render a launcher template from those +validated public fields, but an operator starts `agents-api-codex-executor` separately +on Linux with a private executor credential file. There is no browser-to-executor +connection. + +An optional local-only Docker guide can format that same validated projection with +an operator-configured, non-secret Docker profile. It remains copy-only: the browser +has no Docker socket, never reads the credential path, and never starts or observes +the container. The generated block binds an operator-selected host directory to the +exact executor Workspace path and keeps state under an Environment-specific host +directory. Core Environment reads and events remain the only connection truth. + Core persists the authoritative Session, Turn, Item, and supported Environment views. Reconnecting SSE does not replay missed events, including when `Last-Event-ID` is sent. The current UI therefore reconnects, buffers newly arriving events, retrieves @@ -149,22 +173,37 @@ successful exact HTTP 204, or receiving a permanent rejection creates a new operation. Any other 2xx fails closed and remains uncertain because the documented Session events contract admits writes only with 204. +Idle Session creation follows the same no-automatic-retry boundary. Its modal keeps +the exact Agent/Environment request and idempotency key after failure; only an +explicit unchanged resubmission reuses the key. Editing the draft creates a new key, +and a missing response or Core-generation switch never closes the modal as success. + ## Runtime profiles -The Web currently creates `environment: {"type":"none"}` Sessions. Parsar -`d91ba48a` also exposes Session-bound `self_hosted` data and documented event inputs. -The Web does not create or connect that profile, but for an already selected -self-hosted Session it reads the durable -Environment's exact safe projection and status. Durable `expired` and live-only -`ready` remain separate states; empty installation arrays do not describe a host or -Workspace. This profile is not equivalent to the internal daemon socket, Docker, -E2B, or AWS Bedrock AgentCore Runtime. +The Web creates `environment: {"type":"none"}` Sessions by default. Parsar +`2b34ea46` also admits a Codex-only, Session-scoped `self_hosted` profile. Web exposes +that choice only when the non-secret operator flag is exactly `1`; the absence or any +other value keeps it hidden. The flag is compiled into presentation and is not a +capability probe. The request carries only an absolute executor-host +`workspace_directory` and empty `capability_directories`, creates an idle Session, +and remains subject to Core's configured execution and executor-registry checks. + +For a selected self-hosted Session, Web reads the Environment's exact safe projection +and status. Durable `expired` and live-only `ready` remain separate states; empty +installation arrays do not describe a host or Workspace. Connection and status are +not executor, native-runtime, model, provider, or Turn readiness. The operator-issued +key stays outside Web, and the Linux launcher—not the browser, Web server, or daemon +container—owns access to the Workspace. This profile is not a top-level Environment +catalog, standalone CRUD API, OpenAI-hosted sandbox, Environment template, Files API, +or automatic Docker/E2B/AWS Bedrock AgentCore provisioning flow; those surfaces stay +hidden. `AGENTS_API_ENGINE` selects `codex` by default or the operator-enabled `claude_sdk` profile for new Sessions. The request's model is passed to that engine; it is not an -engine selector. The engine is fixed at Session creation. Core selects and stores a -device binding on first dispatch; subsequent Turns retain that binding and are not -transparently migrated to a replacement device. +engine selector. `self_hosted` requires Codex and must remain disabled in Web for a +Claude-only deployment. The engine is fixed at Session creation. Core selects and +stores a device binding on first dispatch; subsequent Turns retain that binding and +are not transparently migrated to a replacement device. ## Authentication and deployment @@ -173,12 +212,14 @@ product user. Its key binding includes tenant, organization, project, subject ki subject ID, and the digest of a caller bearer. These are explicit operator-assigned execution identities; they do not acquire product-user rights. -The local deployment uses four distinct secret boundaries: +The local deployment uses five distinct secret boundaries: 1. the Vite proxy reads a plaintext `web-token` and injects the Core bearer; 2. Core reads `keys.json`, which contains principal metadata and only the token digest; 3. `parsar-daemon` reads an independently generated device `auth.json`; -4. Codex, Claude, or a provider reads its own credential on the execution host. +4. a self-hosted Linux executor reads an operator-issued connect-only credential + file that Web never receives; +5. Codex, Claude, or a provider reads its own credential on the native harness host. The browser defaults to same-origin `/v1`. A manual token for a direct Core URL is a development fallback held only in the current tab's `sessionStorage`. The pinned @@ -196,7 +237,7 @@ workaround. The development proxy is loopback-only convenience, not a production security boundary. Production must terminate TLS, authenticate Web users, authorize requests, and hold the Core bearer in a reverse proxy/BFF. Use the immutable -[current Parsar setup guide](https://github.com/MiniMax-AI-Dev/parsar/blob/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee/services/agents-api/README.md#standalone-http-service) +[current Parsar setup guide](https://github.com/MiniMax-AI-Dev/parsar/blob/2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e/services/agents-api/README.md#standalone-http-service) for Core lifecycle and treat [Connecting Agent Core](core-connection.md) as a legacy Web runbook pinned to the older revision stated at its top. @@ -206,6 +247,7 @@ Web runbook pinned to the older revision stated at its top. - [Official OpenAI Agents API overview](https://developers.openai.com/api/docs/guides/agents-api/overview) - [Official OpenAI Session lifecycle](https://developers.openai.com/api/docs/guides/agents-api/sessions) - [Pinned `openai-python` Agents resources](https://github.com/openai/openai-python/tree/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents) -- [Parsar Agents API contract at `d91ba48a`](https://github.com/MiniMax-AI-Dev/parsar/blob/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee/contracts/agents-api/README.md) -- [Parsar Environment contract at `d91ba48a`](https://github.com/MiniMax-AI-Dev/parsar/blob/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee/contracts/agents-api/environments.md) -- [Parsar standalone service guide at `d91ba48a`](https://github.com/MiniMax-AI-Dev/parsar/blob/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee/services/agents-api/README.md) +- [Parsar Agents API contract at `2b34ea46`](https://github.com/MiniMax-AI-Dev/parsar/blob/2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e/contracts/agents-api/README.md) +- [Parsar Environment contract at `2b34ea46`](https://github.com/MiniMax-AI-Dev/parsar/blob/2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e/contracts/agents-api/environments.md) +- [Parsar standalone service guide at `2b34ea46`](https://github.com/MiniMax-AI-Dev/parsar/blob/2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e/services/agents-api/README.md) +- [Parsar native Codex executor at `2b34ea46`](https://github.com/MiniMax-AI-Dev/parsar/blob/2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e/packages/codex-executor/README.md) diff --git a/docs/core-connection.md b/docs/core-connection.md index 1af46eb..b235fbc 100644 --- a/docs/core-connection.md +++ b/docs/core-connection.md @@ -476,9 +476,104 @@ unset agent_core_token Do not enable shell tracing while handling secrets. +### Optional self-hosted Session creation + +> This optional Web flow is newer than the legacy runbook snapshot at the top of +> this file. Its immutable capability baseline is Parsar +> [`2b34ea46`](https://github.com/MiniMax-AI-Dev/parsar/commit/2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e). + +Agents Core Web keeps self-hosted Session creation hidden by default because Core +does not expose a public capability-discovery resource. An operator may expose the +known profile by setting this non-secret flag before starting or building Vite: + +```dotenv +AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS=1 +``` + +Restart the development server or rebuild the production bundle after changing it. +The flag enables a form; it does not configure Parsar or prove execution readiness. +Before enabling it, the operator must separately configure a Codex Core with the +executor registry and an externally reachable executor origin as described by the +[pinned native executor prerequisite](https://github.com/MiniMax-AI-Dev/parsar/blob/2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e/services/agents-api/README.md#native-executor-transport-prerequisite). + +The optional form creates an idle Session with no initial model input and this +Environment input only: + +```json +{ + "type": "self_hosted", + "workspace_directory": "/workspace", + "capability_directories": [] +} +``` + +This profile is Codex-only. `workspace_directory` must be an absolute POSIX path on +the executor host, not a path in the browser, Vite server, Agent Core, or +`parsar-daemon` container. The current profile admits only empty/default +`capability_directories`; Web does not expose other Environment input fields. Core +remains authoritative and may reject the request when execution or its executor +registry is unavailable. Web never retries an uncertain Session creation +automatically. A failed attempt keeps the exact form and idempotency key in memory; +an explicit unchanged resubmission reuses that key, while changing the Agent or +Environment draft creates a new operation. + +After creation, Core returns a Session-scoped Environment ID and executor origin. +For a complete, validated response using HTTPS, or loopback HTTP in development, +Web can present the pinned launcher shape with non-secret variables and path +placeholders: + +```bash +agents-api-codex-executor \ + --remote "$REMOTE_URL" \ + --environment-id "$ENVIRONMENT_ID" \ + --credentials "$HOME/.parsar/executor-key.json" \ + --codex-bin /opt/codex/bin/codex +``` + +For an operator-controlled loopback development stack, Web can also render a +ready-to-copy Docker recipe when every `AGENTS_CORE_WEB_DOCKER_*` value documented +in `.env.example` is configured and `AGENTS_CORE_WEB_DOCKER_GUIDE=1`. This is an +explicit local presentation profile, not Core capability discovery. The image, +API-container name, numeric user, credential-file path, and private runtime-root +path are non-secret strings compiled into the local browser bundle; never put a +credential value in them. + +The Docker recipe is offered only for a strict loopback HTTP executor origin and +the same complete Session Environment projection required by the native command. +It creates an Environment-specific state directory below the configured runtime +root, uses a stable full-UUID container name, and bind-mounts the terminal's current +directory at the exact `workspace_directory` returned by Core. Set +`HOST_WORKSPACE_DIRECTORY` before running the copied block to select another +existing host directory. The recipe pins the configured image, shares the named +local API container's network namespace, drops all Linux capabilities, enables +`no-new-privileges`, uses no automatic restart, and never removes an existing +container or directory. + +Copying the recipe does not execute it. Running it is an operator action and may +release input that is already waiting on `environment_connection`, which can start +a paid Turn. Create and connect an idle Session before submitting input when that +is not intended. A missing/invalid local profile, non-loopback origin, unsafe +Workspace mount target, or incomplete Environment projection hides the Docker +recipe while retaining the native launcher fallback. + +Run the launcher on caller-managed Linux x86_64 executor compute, including an +appropriately isolated Linux VM or container, not in the browser. The operator +issues its connect-only credential with `agents-api-environment-key`, delivers the +mode-0600 JSON file directly to that compute, and follows the +[pinned launcher guide](https://github.com/MiniMax-AI-Dev/parsar/blob/2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e/packages/codex-executor/README.md#connect-an-executor). +The credential is not the ordinary Core caller bearer. Never paste it into Web or +place it in `AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS`, `VITE_*`, Session metadata, a +URL, browser storage, fixture, log, screenshot, or Git. + +Web only displays the command and observes Core state. It never issues or reads an +executor key, starts a process/container, or connects to Parsar's native +`/cloud/environment/**` routes or WebSockets. Environment `connected` and live +`ready` are scoped observations, not proof that the executor is isolated, native +preparation succeeded, a model/provider is usable, or a Turn completed. + ### Read-only self-hosted Environment status -At this pinned revision, an existing `self_hosted` Session exposes an Environment ID. +At the `2b34ea46` feature baseline, a `self_hosted` Session exposes an Environment ID. Agents Core Web first retrieves the current Session and then makes one authenticated `GET /v1/agents/environments/{environment_id}`. The response is accepted only when it contains exactly `id`, `object`, `type`, `status`, `files`, `plugins`, and `skills` diff --git a/docs/protocol-coverage.md b/docs/protocol-coverage.md index 80fb88b..ebfd9c2 100644 --- a/docs/protocol-coverage.md +++ b/docs/protocol-coverage.md @@ -6,14 +6,15 @@ OpenAI-hosted service compatibility. ## Compatibility baseline - Current immutable Parsar Core capability baseline: - [`d91ba48a`](https://github.com/MiniMax-AI-Dev/parsar/commit/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee). + [`2b34ea46`](https://github.com/MiniMax-AI-Dev/parsar/commit/2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e). This immutable revision re-confirms the Web-used Agent admission, chat, and Environment and trace-observability boundaries. `OpenAI-Beta: agents=v1` plus the `/v1/agents/**` resources and Session events endpoint are the versioned Web/Core contract. Core exposes no additional public execution-readiness, capability, or build-version resource; Web does not require one before using the documented chat - events. It exposes Environment retrieve plus Session-bound `self_hosted` data, but - no public Environment list, template, file-management, or browser-facing key route. + events. It exposes Codex-only, Session-scoped `self_hosted` creation and Environment + retrieval, but no public Environment list or standalone CRUD, hosted Environment, + template, file-management, or browser-facing key route. - Upstream resource source: `openai-python` 3.13.0 beta Agents resources at [`d7c41efe`](https://github.com/openai/openai-python/tree/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents) - Required beta header: `OpenAI-Beta: agents=v1` @@ -35,7 +36,7 @@ the Core key binding. Agents Core Web's local proxy owns the bearer server-side. | --- | --- | --- | --- | | Saved Agents create/list | Yes | Yes | Dedicated setup covers model, name, instructions, bounded metadata, and the Session-safe text/medium/implicit-reasoning/auto-tier profile; the broader Saved Agent contract is not execution proof | | Saved Agents retrieve/update/delete | Yes | Yes | Agent details support viewing, editing, and deleting saved Agents | -| Sessions create/list/retrieve | Yes | Yes | UI creates idle `environment:none` Sessions; client types also cover the pinned `self_hosted` request and safe response projection | +| Sessions create/list/retrieve | Yes | Yes | UI creates idle `environment:none` Sessions by default; the default-off operator flag can expose the pinned Codex-only, Session-scoped `self_hosted` request and safe response projection | | Sessions update/delete | Yes | Yes | Title/string metadata editing and one-Session confirmed deletion; no bulk or Workspace deletion | | Session live events | Yes | Yes | Authenticated `fetch` stream, not `EventSource` | | Input message / steering | Yes | Yes | Opens SSE before submitting `agent.session.input.message`; only HTTP 204 is durable admission, while Core errors or an unexpected 2xx remain visible and uncertain failures retain the in-memory payload/key for an explicit unchanged manual retry only | @@ -47,19 +48,25 @@ the Core key binding. Agents Core Web's local proxy owns the bearer server-side. | Function result/error | Yes | Yes | Supports text `agent.session.input.tool_result` success/error handoff for exact `function_call` actions | | Initial-input creation stream | Later | No | Idle-create flow avoids the early-event race | | Artifacts/files | Later | No | Required Core resources are not implemented | -| Environment connection action | Yes | Render-only | `environment_connection` is distinct from a function call; Web shows an operator-owned, non-actionable state and sends no result | +| Environment connection action | Yes | Guided, not submitted | `environment_connection` is distinct from a function call; Web can show an operator-run launcher template but sends no result and never opens the native transport | | Environment lifecycle events | Yes | Read-only | UI projects pinned pending, ready, connected, disconnected, and failed live snapshots; unknown/malformed status events clear prior live claims and render as unavailable | -| Environment retrieve | Yes | Yes, read-only | For a valid `self_hosted` Session Environment ID, reads the exact public resource fields and durable status; no create/list/update/delete support | +| Environment retrieve | Yes | Yes, read-only | For a valid `self_hosted` Session Environment ID, reads the exact public resource fields and durable status; there is no standalone Environment create/list/update/delete resource | | Environment overview | No public list API | No top-level UI | Web does not turn loaded Session projections into a catalog; a selected Session may still show its exact Environment data | | Environment templates | No | Hidden | No navigation or Create entry is shown without a Core contract | -| Environment keys | No public browser API | Hidden | Operator-issued executor credentials never enter browser state, request previews, navigation, or Create actions | +| Environment keys | No public browser API | Hidden | Operator-issued executor credentials stay on executor compute and never enter browser state, request previews, navigation, or Create actions | | Vaults | Later | No | Credentials must never be stored in browser metadata | | Protocol Subagents / enabled multi-agent | Later | No | Distinct from storing multiple Agent configurations | | Usage/observability | Response types | Yes, scoped | Session aggregate and per-Turn token Usage are labelled separately; unavailable measurements remain unknown, not zero | ## Runtime boundary -- The Web currently creates only `environment: {"type":"none"}` Sessions. +- The Web creates `environment: {"type":"none"}` Sessions by default. The + non-secret `AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS=1` build/dev-server flag exposes + a second, Codex-only `self_hosted` choice for an operator-reviewed Core deployment. + The flag is off by default because Core has no public capability-discovery route. + It changes Web presentation only; it is not evidence that the connected Core has + configured execution, an executor registry, a reachable executor origin, or a + ready native runtime, model, or provider. - Message, active-Turn steering, cancel, and function-result/error writes use the current `agents=v1` Session events contract. Web does not add a separate private runtime-readiness gate. Only exact HTTP 204 denotes durable event admission; @@ -75,7 +82,7 @@ the Core key binding. Agents Core Web's local proxy owns the bearer server-side. `${AGENTS_CORE_API_KEY}` placeholder; it never reads or renders the connection's server-managed or current-tab bearer. - Saved Agent persistence and Session execution are separate contracts. Parsar - `d91ba48a` can store explicit reasoning, non-`auto` service tiers, and JSON-schema + `2b34ea46` can store explicit reasoning, non-`auto` service tiers, and JSON-schema text formats, but rejects each of them before creating a Session. Enabled multi-agent configuration, saved-only tool types, deferred/invalid/duplicate function or MCP identities, and MCP credentials without attached Vaults are @@ -92,16 +99,38 @@ the Core key binding. Agents Core Web's local proxy owns the bearer server-side. absence of that optional UI is not an error and does not block conversation use. Read-only Environment status is shown only for a real `self_hosted` projection or for an unsupported Environment variant that must fail closed. -- Parsar Core at the audited revision supports Session-bound `self_hosted` data and - its documented event inputs. This Web does not create or connect that profile; it - safely renders selected Sessions that already carry one. That read-only projection - does not expand the missing Environment create/list/update/delete, template, or - file-operation surface. +- Parsar Core at the audited revision supports creating a `self_hosted` Environment + only as part of a Codex Session. When the operator flag is enabled, Web can create + an idle Session with an absolute executor-host `workspace_directory` and exactly + empty `capability_directories`. It does not accept initial input in this setup flow, + create an Environment independently, choose an executor, or claim the requested + Workspace exists. A Core rejection leaves the setup visible and is never retried + automatically. The exact failed draft retains its in-memory idempotency key for an + explicit unchanged manual retry; changing its Agent or Environment fields creates + a new key and operation. - The reusable client distinguishes the admitted `self_hosted` request fields (`workspace_directory` and optional `capability_directories`) from the safe Session response projection (`id`, `remote_url`, `workspace_directory`, and normalized `capability_directories`). Unknown Environment variants remain opaque, inspectable records and are not eligible creation inputs. +- A successful self-hosted Session response supplies the Environment ID and + `remote_url` used by the connection guide. A runnable launcher template is shown + only for a complete known projection with a canonical Environment UUID and a + strictly valid HTTPS executor origin or loopback HTTP development origin. The + template uses static `$REMOTE_URL` and + `$ENVIRONMENT_ID` variables plus credential-file and Codex-binary path placeholders; + it never embeds an executor token, caller bearer, provider credential, URL userinfo, + query, or fragment. Copying the template does not start an executor. +- An optional, default-off local Docker guide is presentation policy layered on + that same strict projection; it is not an Agents API resource or executor + capability. It is rendered only for a loopback HTTP origin and a complete + operator build profile containing non-secret image, API-container, numeric-user, + credential-path, and runtime-root strings. The copied block uses an exact + Environment UUID, a stable per-Environment container/state directory, and an + explicit host-to-Environment Workspace bind. Web never reads the credential, + opens Docker, executes the block, retries it, or treats copying/running it as + connection or runtime readiness. Existing queued input may execute when the + operator connects the Environment. - `required_actions` is a discriminated union. `function_call` carries call, Turn, function-name, and argument fields; `environment_connection` carries only `environment_id`. The initial Web renders the latter as an operator-owned wait and @@ -144,8 +173,12 @@ the Core key binding. Agents Core Web's local proxy owns the bearer server-side. `function_call` result form and has no result submission control. - Workspace means the execution directory within this Environment. It is not a top-level workspaces API, file browser, editor, or artifact capability. The UI - links to the immutable pinned Core and caller-started launcher setup documentation; - it does not connect to daemon/executor transports or mutate Environments. + links to the immutable pinned Core and caller-started launcher setup documentation. + The path must be absolute and already meaningful on the caller-managed Linux + executor; it is not a browser, Web-server, or daemon-container path. The operator + issues the executor credential outside Web, stores it in a private file on that + compute, and starts `agents-api-codex-executor` there. Web never reads the file, + starts a process or container, or connects to daemon/executor transports. - Known Item and Session-event discriminants remain typed. Unknown variants retain their raw fields for inspection, but consumers must treat them as unavailable rather than infer a known rendering or action. @@ -163,7 +196,8 @@ the Core key binding. Agents Core Web's local proxy owns the bearer server-side. - Docker, E2B, and AWS Bedrock AgentCore Runtime each need an upstream lifecycle and capability contract before the Web can advertise them. - `AGENTS_API_ENGINE` selects `codex` or an operator-enabled `claude_sdk` profile for - new Sessions. The browser sends a model ID, not an executor selector. + new Sessions. The pinned `self_hosted` profile is Codex-only; the browser sends a + model ID and Workspace path, not an engine or executor selector. - Core has no standard model-catalog or capability-discovery route in this surface. Web model presets are editable suggestions. Known reasoning, tier, format, multi-agent, executable-tool-shape, and unattached-credential incompatibilities @@ -172,10 +206,11 @@ the Core key binding. Agents Core Web's local proxy owns the bearer server-side. Web never sends a paid Turn merely as a capability probe. Claude SDK accepts medium verbosity only. -Environment creation and management beyond the narrow read, provider selection, -Files, Plugins, Skills, Artifacts, Vault, hosted runtimes, and Workspace lifecycle -controls remain unsupported by this Web or out of scope. Parsar's additional pinned -handlers are not Web-supported merely because they exist upstream. +Environment creation and management beyond the narrow Session-scoped `self_hosted` +creation flow, top-level list/CRUD, provider selection, Files, Plugins, Skills, +Artifacts, Vault, hosted runtimes, templates, key management, and Workspace lifecycle +controls remain unsupported or hidden. Parsar's additional pinned handlers are not +Web-supported merely because they exist upstream. ## Session metadata and deletion @@ -310,7 +345,7 @@ read keeps the existing conversation usable. ## Trace workbench boundary -- Against the immutable Parsar `d91ba48a` capability baseline, the trace workbench +- Against the immutable Parsar `2b34ea46` capability baseline, the trace workbench is a read-only projection of the selected Session's Agent snapshot, all loaded Turn and Item snapshots, and accepted newer lifecycle events. A Turn can contribute its exact status, start/completion timestamps, and available token totals. An Item diff --git a/playwright.config.ts b/playwright.config.ts index 167b1e7..4c9d1af 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -32,7 +32,7 @@ export default defineConfig({ stderr: "pipe", }, { - command: `AGENTS_API_PROXY_TARGET=http://127.0.0.1:${fixturePort} pnpm --filter @agents-core-web/web exec vite --host 127.0.0.1 --mode test --port ${webPort}`, + command: `AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS=1 AGENTS_API_PROXY_TARGET=http://127.0.0.1:${fixturePort} pnpm --filter @agents-core-web/web exec vite --host 127.0.0.1 --mode test --port ${webPort}`, url: `http://127.0.0.1:${webPort}`, reuseExistingServer: false, timeout: 30_000,