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({
)}
- 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");
+ });
+
+ it("offers the operator-gated self-hosted choice without collecting a 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('
@@ -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,