From b88042feed538be90a63133330b3b10154773dc4 Mon Sep 17 00:00:00 2001
From: saladday <1203511142@qq.com>
Date: Tue, 29 Sep 2026 22:38:22 +0800
Subject: [PATCH 1/6] fix(example): use Core self-hosted installation commands
---
CONTRIBUTING.md | 5 +-
example/parsar/README.md | 18 +++---
example/parsar/src/ConnectMachine.tsx | 84 +++++++++++----------------
example/parsar/tests/fixture.mjs | 6 ++
example/parsar/tests/workflow.spec.ts | 24 ++++++--
5 files changed, 70 insertions(+), 67 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b0aa35e7b..dd261ae16 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -156,8 +156,9 @@ databases, credentials and migrations. The product uses Core exclusively; it has
[README](example/parsar/README.md) owns its product behavior. The boundary rules are:
- It calls only public `/v1` APIs. Its Project key stays server-side; it never
- holds a Core key or issues machine credentials. Core-only credential issuance
- stays in the operator console.
+ holds a Core key or issues machine credentials. Self-hosted connection displays
+ the public Session installation command unchanged; Core owns bootstrap
+ authorization and machine credential issuance.
- It may reuse product UI and keep a small product-owned SQLite database (Node's
built-in module, Node 22.13+), outside the checkout and isolated by Core origin
and Project key fingerprint. Provider keys never reach the browser.
diff --git a/example/parsar/README.md b/example/parsar/README.md
index d795cf169..8bb30ae88 100644
--- a/example/parsar/README.md
+++ b/example/parsar/README.md
@@ -146,16 +146,14 @@ Agent organization informs the product flow; no Multica code is copied.
## Connect a user machine
-Run a matching Core and daemon version.
-
Create a user-machine runtime and start a Session with an Agent using Codex or
-Claude Code. No initial Turn is sent. Open **Connect user machine**, obtain the
-Session's executor credential in Core Web (Session log), save the credential on
-the host as instructed, and run the generated platform-specific install/start
-command. The daemon binary and native harness must already be installed. Windows
-Claude Code also requires Git Bash. The page reads Core's public Environment
+Claude Code. No initial Turn is sent. Open **Connect user machine** and run the
+Core-provided command on the target host. The installer downloads the matching
+native distribution, installs the selected harness and starts the connection.
+Windows Claude Code also requires Git Bash. The page reads Core's public Environment
status and enables sending after it reports connected. The example backend does
-not need or accept the administrator Core key.
+not need or accept the administrator Core key. Commands carry temporary
+Environment-scoped authorization; do not share them. Session refresh renews them.
The selected model Provider needs an HTTPS Base URL and API key. Its protocol is
Responses for Codex and Anthropic for Claude Code. Core freezes and delivers the
@@ -170,6 +168,4 @@ included in this example because its required token limits are not exposed.
The daemon runs with the starting user's permissions and adds no sandbox. Runtime
home is separate per Session; stopping it preserves local files. Credential
-rotation and revocation remain Core console operations. Native install commands
-are for fresh installations; restart an existing one with the same Runtime home
-and `start`, without rerunning `install`.
+rotation and revocation remain Core console operations. Reuse the original installation directory when reconnecting.
diff --git a/example/parsar/src/ConnectMachine.tsx b/example/parsar/src/ConnectMachine.tsx
index 214f25f73..097817b80 100644
--- a/example/parsar/src/ConnectMachine.tsx
+++ b/example/parsar/src/ConnectMachine.tsx
@@ -15,9 +15,6 @@ import {
} from "./components/ui/dialog";
import { ErrorNotice, Help } from "./components/shared";
-const shell = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
-const powershell = (value: string) => `'${value.replaceAll("'", "''")}'`;
-
export function ConnectMachine({
session,
machine,
@@ -28,7 +25,7 @@ export function ConnectMachine({
onConnected: (connected: boolean) => void;
}) {
const [open, setOpen] = useState(false);
- const [copied, setCopied] = useState(false);
+ const [copied, setCopied] = useState("");
const environment = session.environment as SelfHostedAgentEnvironment;
const id = environment.type === "self_hosted" ? environment.id : "";
const query = useQuery({
@@ -50,9 +47,12 @@ export function ConnectMachine({
};
const status = query.data?.status;
const windows = machine.platform === "windows";
- const command = windows
- ? `$env:OAC_RUNTIME_HOME = "$HOME\\.oac\\sessions\\${id}"\noac-daemon.exe install --remote ${powershell(environment.remote_url)} --environment-id ${powershell(id)} --workspace ${powershell(machine.workspace_directory)} --credential-file "$HOME\\executor-credential-${id}.json"\noac-daemon.exe start`
- : `export OAC_RUNTIME_HOME="$HOME/.oac/sessions/${id}"\nchmod 600 "$HOME/executor-credential-${id}.json"\noac-daemon install --remote ${shell(environment.remote_url)} --environment-id ${shell(id)} --workspace ${shell(machine.workspace_directory)} --credential-file "$HOME/executor-credential-${id}.json"\noac-daemon start`;
+ const installation = session.x_agents_core?.installation;
+ const command =
+ installation?.status === "available" &&
+ (installation.expires_at ?? 0) > Date.now() / 1000
+ ? installation.commands?.[windows ? "powershell" : "posix"]
+ : undefined;
return (
<>
@@ -76,53 +76,39 @@ export function ConnectMachine({
连接用户机器
-
- 1. 在用户机器安装与 Core 同版本的 oac-daemon,以及此 Agent 使用的
- Codex 或 Claude Code。确认工作目录已经存在。
-
+
在用户机器的{windows ? " PowerShell" : "终端"}中运行安装命令。
- 安装前提
+ 工作目录:{machine.workspace_directory}
- Linux、macOS 使用原生 daemon;Windows 使用
- oac-daemon.exe,Claude Code 还需要 Git Bash。daemon
- 使用启动用户的权限,不提供文件或网络隔离。
+ 安装器会下载与 Core 匹配的 Runtime 和所选执行引擎,创建工作目录并连接此会话。
+ Windows 上 Claude Code 还需要 Git Bash。Runtime 使用启动用户的权限。
-
- 2. 在 Core 控制台的「Session
- log」打开此会话,签发执行凭据并下载到用户机器的主目录,命名为:
-
-
- executor-credential-{id}.json
-
-
- Core 会话
- {session.id}
- void navigator.clipboard.writeText(session.id)}
- >
- 复制 ID
-
-
-
3. 在{windows ? " PowerShell" : "终端"}执行:
-
- {command}
-
-
{
- await navigator.clipboard.writeText(command);
- setCopied(true);
- }}
- >
- {copied ? "已复制" : "复制命令"}
-
-
显示「已连接」后,回到会话发送消息。
+ {command ? (
+ <>
+
+ {command}
+
+
+ {
+ await navigator.clipboard.writeText(command);
+ setCopied(command);
+ }}
+ >
+ {copied === command ? "已复制" : "复制命令"}
+
+ 命令包含临时连接授权,请勿分享。过期后页面会自动获取新命令。
+
+ >
+ ) : (
+
安装命令暂不可用,请检查 Core 的原生安装包配置,或稍后重试。
+ )}
+
安装完成并显示「已连接」后,即可发送消息。
- 每个会话使用独立的 Runtime 主目录和连接凭据。已有安装只需使用相同
- OAC_RUNTIME_HOME 运行 start,不要重复 install。stop
- 会保留机器文件和会话历史。
+ 每个会话使用独立的 Runtime 安装目录。续聊使用原会话和工作目录;
+ 重新连接时使用原安装目录,保留会话历史和文件。
{
plugins: [],
skills: [],
},
+ ...(body.environment.type === "self_hosted" ? {
+ x_agents_core: { installation: {
+ status: "available", version: "fixture", expires_at: now() + 1800,
+ commands: { posix: "bash fixture-native-bootstrap.sh", powershell: "& fixture-native-bootstrap.ps1" },
+ } },
+ } : {}),
status: "idle",
error: null,
metadata: body.metadata,
diff --git a/example/parsar/tests/workflow.spec.ts b/example/parsar/tests/workflow.spec.ts
index 18255cca2..2aed9b837 100644
--- a/example/parsar/tests/workflow.spec.ts
+++ b/example/parsar/tests/workflow.spec.ts
@@ -93,7 +93,10 @@ test.beforeEach(async ({ request }) => {
await request.post("http://127.0.0.1:18181/reset");
});
-test("self-hosted Session shows native connection instructions and waits before sending", async ({
+for (const platform of [
+ { label: "macOS", path: "/Users/example/project", command: "bash fixture-native-bootstrap.sh" },
+ { label: "Windows", path: "C:\\Users\\example\\project", command: "& fixture-native-bootstrap.ps1" },
+]) test(`self-hosted ${platform.label} uses Core's command and waits before sending`, async ({
page,
request,
}) => {
@@ -110,10 +113,10 @@ test("self-hosted Session shows native connection instructions and waits before
await page.getByLabel("环境类型").click();
await page.getByRole("option", { name: "用户机器", exact: true }).click();
await page.getByLabel("机器平台").click();
- await page.getByRole("option", { name: "macOS", exact: true }).click();
+ await page.getByRole("option", { name: platform.label, exact: true }).click();
await page
.getByLabel("工作目录", { exact: true })
- .fill("/Users/example/project");
+ .fill(platform.path);
await page.getByRole("button", { name: "保存", exact: true }).click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await navigate(page, "Agents");
@@ -138,10 +141,21 @@ test("self-hosted Session shows native connection instructions and waits before
page.getByRole("button", { name: "发送", exact: true }),
).toBeDisabled();
await page.getByRole("button", { name: "连接用户机器", exact: true }).click();
- await expect(page.getByRole("dialog")).toContainText("oac-daemon install");
+ await expect(page.getByRole("dialog")).toContainText(platform.command);
await expect(page.getByRole("dialog")).toContainText(
- "/Users/example/project",
+ platform.path,
);
+ const sessionRoute = "**/v1/agents/sessions/*";
+ await page.route(sessionRoute, async (route) => {
+ const response = await route.fetch();
+ const body = await response.json();
+ body.x_agents_core.installation.expires_at = 1;
+ await route.fulfill({ response, json: body });
+ });
+ await expect(page.getByRole("dialog")).toContainText("安装命令暂不可用");
+ await expect(page.getByRole("button", { name: "复制命令", exact: true })).toHaveCount(0);
+ await page.unroute(sessionRoute);
+ await expect(page.getByRole("dialog")).toContainText(platform.command);
await page.getByRole("button", { name: "Close", exact: true }).click();
await request.post("http://127.0.0.1:18181/connect-executor");
await expect(page.getByText("已连接", { exact: true })).toBeVisible();
From 88f5e651ef3b0b1f265791b36c2ed9768f5b2fb3 Mon Sep 17 00:00:00 2001
From: saladday <1203511142@qq.com>
Date: Wed, 30 Sep 2026 00:49:29 +0800
Subject: [PATCH 2/6] feat(example): complete provider and workspace file flows
---
example/parsar/README.md | 27 ++-
example/parsar/server.mjs | 21 ++-
example/parsar/server/sessions.mjs | 31 +---
example/parsar/shared/session-profile.d.mts | 4 +
example/parsar/shared/session-profile.mjs | 16 ++
example/parsar/src/Composer.tsx | 24 ++-
example/parsar/src/ConfigurationFields.tsx | 2 +-
example/parsar/src/NewSession.tsx | 23 ++-
example/parsar/src/ProviderEditor.tsx | 3 +-
example/parsar/src/SessionDetail.tsx | 68 +++++--
example/parsar/src/SessionFiles.tsx | 193 ++++++++++++++++++++
example/parsar/src/SessionPage.tsx | 1 +
example/parsar/tests/fixture.mjs | 17 ++
example/parsar/tests/product.test.mjs | 20 +-
example/parsar/tests/server.test.mjs | 32 ++++
example/parsar/tests/workflow.spec.ts | 101 ++++++++++
16 files changed, 533 insertions(+), 50 deletions(-)
create mode 100644 example/parsar/shared/session-profile.d.mts
create mode 100644 example/parsar/shared/session-profile.mjs
create mode 100644 example/parsar/src/SessionFiles.tsx
diff --git a/example/parsar/README.md b/example/parsar/README.md
index 8bb30ae88..406f398c1 100644
--- a/example/parsar/README.md
+++ b/example/parsar/README.md
@@ -33,9 +33,12 @@ For a built version, run `pnpm --filter @oac/parsar-example build` and then
to the browser; leaving the key blank while editing preserves it. Discovery
sends only that Provider's key and does not follow redirects. The list expects
the OpenAI-shaped `data: [{id: "..."}]` response. Discovery failures leave manual
- entry available. This imports the catalog only: Core still owns execution
- connections for hosted/text-only Sessions. Self-hosted Sessions instead pass
- this Provider URL/key explicitly to Core, using the selected harness protocol.
+ entry available. Codex and Claude Code workspace Sessions pass the selected Provider URL/key
+ explicitly to Core, using the harness protocol. These Sessions require an HTTPS
+ Base URL and API key. Text-only Sessions and hosted MiniMax Code use Core's
+ deployment connection; the creation dialog states that their Provider is only
+ a catalog group. The example does not yet expose MiniMax Code's required token
+ limits.
2. **Skills:** create a SKILL.md resource or upload a ZIP. Inspect versions,
upload a new version, and choose the default. Core validates and stores bundles.
3. **MCP:** save anonymous HTTPS endpoints and bind them to Agents. Hosted
@@ -53,7 +56,18 @@ For a built version, run `pnpm --filter @oac/parsar-example build` and then
Create, copy and edit configurations without allocating a runtime.
6. **Sessions:** open an Agent, choose a runtime and send the first message.
Read streaming replies and tool activity, continue the conversation, cancel a running turn and reopen
- history. Each hosted Session gets its own workspace; text-only has no workspace.
+ history. Unsupported Skill/MCP/runtime combinations are explained before creation.
+ The Session's **Files** dialog uploads one file at a time (up to 5 MiB) through
+ Core's public Environment Files API while idle. Each upload gets a unique path
+ under `/workspace/inputs`; a path relative to the working directory is inserted into the message draft,
+ not sent automatically. Unsent drafts, including uploaded file paths, survive
+ reloads and navigation in the same browser tab and remain scoped to the Session. This keeps the API's `/workspace` alias distinct from
+ the physical user-machine directory. Ask the Agent to save generated files
+ under `./outputs`;
+ published Artifacts can be paged and downloaded from the same dialog, even when
+ the live workspace is unavailable. Downloads remain binary; errors stay in the
+ dialog. Text-only Sessions have no file actions.
+ Each hosted Session gets its own workspace; text-only has no workspace.
Agent edits and catalog updates apply to future Sessions. Core freezes existing
Session configuration, including resolved Skill versions. Continuing a Session
@@ -71,7 +85,7 @@ execution database or imported Parsar backend.
SQLite files live in `~/.oac/data/parsar-example/`. Set the absolute
`OAC_EXAMPLE_DATA_DIR` to relocate them. A hash of the Core origin and Project key
selects the file; changing either selects a different local catalog. Back up the
-SQLite file with the server stopped. No key is stored in it. Builds and caches
+SQLite file with the server stopped. The Core Project key is not stored in it. Provider keys are stored there. Builds and caches
use `${OAC_DEV_HOME:-$HOME/.oac}`.
Use one server process and a dedicated Project for this local single-user example.
@@ -97,7 +111,8 @@ A few more implementation details:
- **Providers and models** are saved together in one SQLite transaction. Editing
reads a Provider and its models as one snapshot; changing a model advances the
Provider's revision.
-- **Browser calls** use `OpenAIAgentsClient`; only Session creation goes through a
+- **Browser calls** use `OpenAIAgentsClient`, with a small public HTTP reader for
+ Artifacts (not yet exposed by that client); only Session creation goes through a
small server adapter. Closing the browser aborts the upstream stream, never the
running Session.
- **Workspaces:** hosted Sessions each get their own. User-machine Sessions use the
diff --git a/example/parsar/server.mjs b/example/parsar/server.mjs
index a4557c5d9..ff98aacd6 100644
--- a/example/parsar/server.mjs
+++ b/example/parsar/server.mjs
@@ -11,6 +11,12 @@ import { productAPI } from "./server/product.mjs";
const root = fileURLToPath(new URL(".", import.meta.url));
const routes = [
[/^\/v1\/agents\/environments\/[a-f0-9-]{36}$/, ["GET"]],
+ [/^\/v1\/agents\/environments\/[a-f0-9-]{36}\/files$/, ["GET", "POST"]],
+ [/^\/v1\/agents\/sessions\/[a-f0-9-]{36}\/artifacts$/, ["GET"]],
+ [
+ /^\/v1\/agents\/sessions\/[a-f0-9-]{36}\/artifacts\/[a-f0-9-]{36}\/content$/,
+ ["GET"],
+ ],
[/^\/v1\/agents\/sessions\/[a-f0-9-]{36}$/, ["GET"]],
[/^\/v1\/agents\/sessions\/[a-f0-9-]{36}\/(items|turns)$/, ["GET"]],
[/^\/v1\/agents\/sessions\/[a-f0-9-]{36}\/events$/, ["GET", "POST"]],
@@ -198,7 +204,8 @@ export function createHandler(
size += chunk.length;
if (
size >
- (url.pathname.startsWith("/v1/skills")
+ (url.pathname.startsWith("/v1/skills") ||
+ url.pathname.endsWith("/files")
? 8 * 1024 * 1024
: 1024 * 1024)
)
@@ -249,6 +256,18 @@ export function createHandler(
await pipeline(Readable.fromWeb(upstream.body), res);
return;
}
+ if (url.pathname.endsWith("/content") && upstream.ok && upstream.body) {
+ // Downloads stay binary and are never rendered as active browser content.
+ clearTimeout(timer);
+ res.writeHead(upstream.status, {
+ "Content-Type": "application/octet-stream",
+ "Content-Disposition":
+ upstream.headers.get("content-disposition") || "attachment",
+ "Cache-Control": "no-store",
+ });
+ await pipeline(Readable.fromWeb(upstream.body), res);
+ return;
+ }
const body = await upstream.text();
res.writeHead(upstream.status, {
"Content-Type": "application/json",
diff --git a/example/parsar/server/sessions.mjs b/example/parsar/server/sessions.mjs
index d275e05a8..2cfdd72e6 100644
--- a/example/parsar/server/sessions.mjs
+++ b/example/parsar/server/sessions.mjs
@@ -1,3 +1,4 @@
+import { sessionRestriction } from "../shared/session-profile.mjs";
import { zipSync, strToU8 } from "fflate";
import { AppError, text, uuid } from "./store.mjs";
@@ -14,41 +15,28 @@ function requestFor(store, id, body) {
throw new AppError(400, "绑定的 MCP 服务标识不能重复。");
const environment = { type: runtime.environment };
const selfHosted = environment.type === "self_hosted";
+ const restriction = sessionRestriction(agent, runtime);
+ if (restriction) throw new AppError(400, restriction);
let modelProvider;
- if (selfHosted) {
- if (agent.skill_ids.length)
- throw new AppError(
- 400,
- "用户机器使用本地能力目录,当前 Core 不接受托管 Skill 引用。请使用未绑定托管 Skill 的 Agent,并在运行时填写本地能力目录。",
- );
- if (mcps.length)
- throw new AppError(
- 400,
- "用户机器的 MCP 请通过本地 Plugin 能力目录配置。当前 Core 不接受此处的 MCP 服务绑定,请使用未绑定 MCP 的 Agent。",
- );
- if (agent.harness === "mcode")
- throw new AppError(
- 400,
- "此示例的用户机器接入先支持 Codex 和 Claude Code,请选择其中一个执行引擎。",
- );
+ if (environment.type !== "none" && agent.harness !== "mcode") {
const provider = store.get("providers", model.provider_id);
if (!provider?.base_url?.startsWith("https://") || !provider.api_key)
throw new AppError(
400,
- "用户机器需要模型 Provider 的 HTTPS Base URL 和 API Key,请先在模型页面配置。",
+ "请先在模型页面配置所选 Provider 的 HTTPS Base URL 和 API Key。",
);
modelProvider = {
protocol: agent.harness === "codex" ? "responses" : "anthropic",
base_url: provider.base_url,
api_key: provider.api_key,
};
+ }
+ if (selfHosted) {
environment.workspace_directory = runtime.workspace_directory;
environment.capability_directories = runtime.capability_directories || [];
}
const tools = [{ type: "web_search", mode: "disabled" }];
if (environment.type === "none" || selfHosted) {
- if (agent.skill_ids.length)
- throw new AppError(400, "Skills 需要托管运行环境。");
tools.push(
...mcps.map((mcp) => ({
type: "mcp",
@@ -64,11 +52,6 @@ function requestFor(store, id, body) {
skill_id,
}));
if (mcps.length) {
- if (agent.harness === "mcode")
- throw new AppError(
- 400,
- "MiniMax Code 暂不支持托管环境中的 HTTP MCP,请选择 Claude Code 或 Codex。",
- );
const name = "agent-mcp";
const description = "Agent HTTP MCP bindings";
const archive = zipSync({
diff --git a/example/parsar/shared/session-profile.d.mts b/example/parsar/shared/session-profile.d.mts
new file mode 100644
index 000000000..6b4d31f52
--- /dev/null
+++ b/example/parsar/shared/session-profile.d.mts
@@ -0,0 +1,4 @@
+export function sessionRestriction(
+ agent: { harness: string; skill_ids: string[]; mcp_ids: string[] },
+ runtime?: { environment: string },
+): string | undefined;
diff --git a/example/parsar/shared/session-profile.mjs b/example/parsar/shared/session-profile.mjs
new file mode 100644
index 000000000..96ea469fd
--- /dev/null
+++ b/example/parsar/shared/session-profile.mjs
@@ -0,0 +1,16 @@
+// Product guidance only; Core remains authoritative for capability validation.
+export function sessionRestriction(agent, runtime) {
+ if (!runtime) return undefined;
+ if (runtime.environment === "self_hosted") {
+ if (agent.skill_ids.length)
+ return "用户机器使用本地能力目录,不能绑定托管 Skill。请先编辑 Agent,移除托管 Skill。";
+ if (agent.mcp_ids.length)
+ return "用户机器的 MCP 需通过本地 Plugin 配置。请先编辑 Agent,移除 MCP 绑定。";
+ if (agent.harness === "mcode")
+ return "当前示例的用户机器支持 Codex 和 Claude Code,请先编辑 Agent 的执行引擎。";
+ }
+ if (runtime.environment === "none" && agent.skill_ids.length)
+ return "Skills 需要托管运行环境,请选择托管沙箱或移除 Skill 绑定。";
+ if (agent.harness === "mcode" && agent.mcp_ids.length)
+ return "MiniMax Code 暂不支持此处的 HTTP MCP,请选择 Claude Code 或 Codex。";
+}
diff --git a/example/parsar/src/Composer.tsx b/example/parsar/src/Composer.tsx
index 14d1cd9c2..1517441f1 100644
--- a/example/parsar/src/Composer.tsx
+++ b/example/parsar/src/Composer.tsx
@@ -14,10 +14,14 @@ export function Composer({
session,
turnId,
connectionPending = false,
+ attachment = "",
+ onPendingChange,
}: {
session: AgentSession;
turnId?: string;
connectionPending?: boolean;
+ attachment?: string;
+ onPendingChange?: (pending: boolean) => void;
}) {
const cache = useQueryClient();
const storageKey = `oac-example-message-${session.id}`;
@@ -30,7 +34,24 @@ export function Composer({
}
},
);
- const [text, setText] = useState(pending?.text || "");
+ useEffect(() => {
+ onPendingChange?.(Boolean(pending));
+ }, [pending, onPendingChange]);
+ const draftKey = `oac-example-draft-${session.id}`;
+ const [text, setText] = useState(
+ () => pending?.text || sessionStorage.getItem(draftKey) || "",
+ );
+ useEffect(() => {
+ if (text) sessionStorage.setItem(draftKey, text);
+ else sessionStorage.removeItem(draftKey);
+ }, [draftKey, text]);
+ useEffect(() => {
+ if (attachment)
+ setText(
+ (previous) =>
+ `${previous}${previous ? "\n" : ""}请处理文件:${attachment}`,
+ );
+ }, [attachment]);
const [cancelKey, setCancelKey] = useState(createIdempotencyKey);
const [cancelRequested, setCancelRequested] = useState(false);
useEffect(() => {
@@ -62,6 +83,7 @@ export function Composer({
requestReturned(session.id, operation.key);
},
onSuccess: () => {
+ sessionStorage.removeItem(draftKey);
setText("");
setPending(null);
sessionStorage.removeItem(storageKey);
diff --git a/example/parsar/src/ConfigurationFields.tsx b/example/parsar/src/ConfigurationFields.tsx
index 928059071..a76a165d3 100644
--- a/example/parsar/src/ConfigurationFields.tsx
+++ b/example/parsar/src/ConfigurationFields.tsx
@@ -94,7 +94,7 @@ export function ConfigurationFields({
{group.key === "skill_ids"
? "最多选择 8 个技能,运行时需支持托管沙箱。新会话使用技能的默认版本。"
- : "最多选择 8 个 MCP。Core 负责连接服务,服务地址需从 Core 网络可达。"}
+ : "最多选择 8 个 MCP。托管会话从工作区连接,纯文本会话从 Core 连接;用户机器请在本地 Plugin 中配置。"}
diff --git a/example/parsar/src/NewSession.tsx b/example/parsar/src/NewSession.tsx
index ae53ace07..eefef33ff 100644
--- a/example/parsar/src/NewSession.tsx
+++ b/example/parsar/src/NewSession.tsx
@@ -1,3 +1,4 @@
+import { sessionRestriction } from "../shared/session-profile.mjs";
import { useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
@@ -36,9 +37,11 @@ export function NewSession({
agent_id: agent.id,
runtime_id: "",
}));
- const selfHosted =
- runtimes.data?.find((runtime) => runtime.id === form.runtime_id)
- ?.environment === "self_hosted";
+ const runtime = runtimes.data?.find(
+ (runtime) => runtime.id === form.runtime_id,
+ );
+ const selfHosted = runtime?.environment === "self_hosted";
+ const restriction = sessionRestriction(agent, runtime);
const startedAt = useRef
(0);
const [uncertain, setUncertain] = useState(false);
const save = useMutation({
@@ -132,6 +135,19 @@ export function NewSession({
/>
)}
+ {restriction && (
+
+ {restriction}
+
+ )}
+ {!restriction &&
+ runtime &&
+ (runtime.environment === "none" || agent.harness === "mcode") && (
+
+ 此组合使用 Core 部署的模型连接;这里选择的 Provider
+ 仅用于模型分组。
+
+ )}
{uncertain && (
如请求结果未确定,可关闭弹窗,在会话列表中恢复同一次创建。
@@ -147,6 +163,7 @@ export function NewSession({
form="session-form"
type="submit"
disabled={
+ Boolean(restriction) ||
uncertain ||
save.isPending ||
!form.runtime_id ||
diff --git a/example/parsar/src/ProviderEditor.tsx b/example/parsar/src/ProviderEditor.tsx
index 7b5cf7591..876346db5 100644
--- a/example/parsar/src/ProviderEditor.tsx
+++ b/example/parsar/src/ProviderEditor.tsx
@@ -224,8 +224,7 @@ function ProviderForm({
模型连接
地址和密钥用于获取此 Provider
- 的模型列表。密钥仅保存在本机后端,不返回浏览器。执行会话仍使用
- Core 已配置的模型连接。
+ 的模型列表。密钥仅保存在本机后端,不返回浏览器。Codex 和 Claude Code 的托管及用户机器会话使用此连接;纯文本和 MiniMax Code 使用 Core 部署的连接。
diff --git a/example/parsar/src/SessionDetail.tsx b/example/parsar/src/SessionDetail.tsx
index db07a45e8..56dbbe1a6 100644
--- a/example/parsar/src/SessionDetail.tsx
+++ b/example/parsar/src/SessionDetail.tsx
@@ -24,6 +24,7 @@ import { useSendTiming, timingLabel } from "./lib/session-timing";
import { Help } from "./components/shared";
import { useLiveSession } from "./lib/live-session";
import { Composer } from "./Composer";
+import { SessionFiles } from "./SessionFiles";
import { ConnectMachine } from "./ConnectMachine";
import type { SessionRecord } from "./lib/product";
@@ -38,6 +39,9 @@ export function SessionDetail({
}) {
const timing = useSendTiming(id);
const [info, setInfo] = useState(false);
+ const [messagePending, setMessagePending] = useState(false);
+ const [files, setFiles] = useState(false);
+ const [attachment, setAttachment] = useState("");
const [machineConnected, setMachineConnected] = useState(false);
const viewport = useRef
(null);
const followOutput = useRef(true);
@@ -60,6 +64,12 @@ export function SessionDetail({
viewport.current.scrollTop = viewport.current.scrollHeight;
}, [live.items]);
const session = data?.session;
+ const environmentId =
+ session &&
+ "id" in session.environment &&
+ typeof session.environment.id === "string"
+ ? session.environment.id
+ : undefined;
const state = session ? sessionState(session, data?.turns[0]) : undefined;
return (
<>
@@ -78,6 +88,11 @@ export function SessionDetail({
{state.label}
)}
+ {environmentId && (
+ setFiles(true)}>
+ 文件
+
+ )}
+ {files && session && environmentId && (
+ setFiles(false)}
+ useFile={setAttachment}
+ />
+ )}
{session && machine && (
)}
{data.session.status === "in_progress" && }
- {data.session.status === "requires_action" && (
-
- 此会话需要外部操作,当前示例暂不支持处理。可取消本次执行。
-
- 查看所需操作
-
- {JSON.stringify(data.session.required_actions, null, 2)}
-
-
-
- )}
+ {data.session.status === "requires_action" &&
+ data.session.required_actions.some(
+ (action) =>
+ !machine || action.type !== "environment_connection",
+ ) && (
+
+ 此会话需要外部操作,当前示例暂不支持处理。可取消本次执行。
+ setInfo(true)}>
+ 查看所需操作
+
+
+ )}
{timing && (
@@ -174,6 +204,8 @@ export function SessionDetail({
)}
+ {session.required_actions.some(
+ (action) => !machine || action.type !== "environment_connection",
+ ) && (
+
+ {JSON.stringify(
+ session.required_actions.filter(
+ (action) =>
+ !machine || action.type !== "environment_connection",
+ ),
+ null,
+ 2,
+ )}
+
+ )}
)}
diff --git a/example/parsar/src/SessionFiles.tsx b/example/parsar/src/SessionFiles.tsx
new file mode 100644
index 000000000..8a6c970ae
--- /dev/null
+++ b/example/parsar/src/SessionFiles.tsx
@@ -0,0 +1,193 @@
+import { useRef } from "react";
+import { useInfiniteQuery, useMutation } from "@tanstack/react-query";
+import { Upload, Download } from "lucide-react";
+import type { ListPage, SessionArtifact } from "@agents-core-web/agents-client";
+import { api, dateTime } from "./lib/api";
+import { Button } from "./components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "./components/ui/dialog";
+import { ErrorNotice, Help } from "./components/shared";
+
+const maxUploadBytes = 5 * 1024 * 1024;
+
+async function upload(environmentId: string, file: File) {
+ if (file.size > maxUploadBytes) throw new Error("文件不能超过 5 MiB。");
+ const name =
+ file.name.replace(/[\\/:*?"<>|\x00-\x1f\x7f]/g, "_").slice(0, 100) ||
+ "file";
+ const path = `/workspace/inputs/${crypto.randomUUID()}-${name}`;
+ const data = await new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result).split(",")[1] || "");
+ reader.onerror = () => reject(new Error("无法读取文件,请重新选择。"));
+ reader.readAsDataURL(file);
+ });
+ await api.createEnvironmentFile(environmentId, {
+ type: "inline",
+ path,
+ data,
+ });
+ return path;
+}
+
+export function SessionFiles({
+ sessionId,
+ environmentId,
+ writable,
+ close,
+ useFile,
+}: {
+ sessionId: string;
+ environmentId: string;
+ writable: boolean;
+ close: () => void;
+ useFile: (path: string) => void;
+}) {
+ const input = useRef(null);
+ const artifacts = useInfiniteQuery({
+ queryKey: ["artifacts", sessionId],
+ initialPageParam: "",
+ queryFn: async ({
+ pageParam,
+ signal,
+ }): Promise> => {
+ const params = new URLSearchParams({ limit: "50", order: "desc" });
+ if (pageParam) params.set("after", pageParam);
+ const response = await fetch(
+ `/v1/agents/sessions/${encodeURIComponent(sessionId)}/artifacts?${params}`,
+ { signal },
+ );
+ const value = await response.json();
+ if (!response.ok)
+ throw new Error(value.error?.message || "无法读取产物。");
+ return value;
+ },
+ getNextPageParam: (page) =>
+ page.has_more ? page.last_id || undefined : undefined,
+ });
+ const send = useMutation({
+ mutationFn: (file: File) => upload(environmentId, file),
+ onSuccess: (path) => {
+ useFile(path.replace(/^\/workspace\//, "./"));
+ close();
+ },
+ });
+ const download = useMutation({
+ mutationFn: async (file: SessionArtifact) => {
+ const response = await fetch(
+ `/v1/agents/sessions/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(file.id)}/content`,
+ );
+ if (!response.ok) {
+ const value = await response.json();
+ throw new Error(value.error?.message || "下载失败,请重试。");
+ }
+ const url = URL.createObjectURL(await response.blob());
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = file.path.split("/").at(-1) || "artifact";
+ link.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ },
+ });
+ const files = artifacts.data?.pages.flatMap((page) => page.data) || [];
+ return (
+ {
+ if (!open && !send.isPending) close();
+ }}
+ >
+
+
+ 会话文件
+
+
+ input.current?.click()}
+ >
+
+ {send.isPending ? "上传中…" : "上传文件"}
+
+
+ 每次上传一个文件,最大 5
+ MiB。文件进入当前工作区,路径会插入消息草稿;发送后 Agent
+ 才会处理。执行期间不能上传。让 Agent 将结果保存到当前工作目录的
+ outputs/,执行完成后可在这里下载。
+
+ {
+ const file = event.target.files?.[0];
+ if (file) send.mutate(file);
+ event.target.value = "";
+ }}
+ />
+
+ {!writable && (
+
+ 会话空闲且运行环境就绪后可以上传。
+
+ )}
+ void artifacts.refetch()
+ : undefined
+ }
+ />
+ 生成的文件
+
+ {artifacts.isPending ? (
+
正在读取…
+ ) : !files.length && !artifacts.error ? (
+
还没有生成的文件。
+ ) : null}
+ {files.map((file) => (
+
+
+
+ {file.path.replace(/^\/workspace\/outputs\//, "")}
+
+
+ {Math.ceil(file.size_bytes / 1024)} KiB ·{" "}
+ {dateTime(file.created_at)}
+
+
+
download.mutate(file)}
+ aria-label={`下载 ${file.path.split("/").at(-1)}`}
+ >
+
+
+
+ ))}
+
+ {artifacts.hasNextPage && (
+ void artifacts.fetchNextPage()}
+ >
+ 加载更多
+
+ )}
+
+
+ );
+}
diff --git a/example/parsar/src/SessionPage.tsx b/example/parsar/src/SessionPage.tsx
index d5ee2a7cc..b2085d1d9 100644
--- a/example/parsar/src/SessionPage.tsx
+++ b/example/parsar/src/SessionPage.tsx
@@ -20,6 +20,7 @@ export function SessionPage({ id }: { id: string }) {
if (query.data?.core_session_id)
return (
{
}
return reply(session, 201);
}
+ const fileMatch = url.pathname.match(/^\/v1\/agents\/environments\/([^/]+)\/files$/);
+ if (fileMatch) {
+ if (req.method === "POST") return reply({
+ object: "agent.environment.file", environment_id: fileMatch[1], path: body.path,
+ size_bytes: Buffer.from(body.data, "base64").length,
+ }, 201);
+ }
+ const artifactMatch = url.pathname.match(/^\/v1\/agents\/sessions\/([^/]+)\/artifacts(?:\/([^/]+)\/content)?$/);
+ if (artifactMatch) {
+ if (artifactMatch[2]) {
+ res.writeHead(200, { "Content-Type": "application/octet-stream", "Content-Disposition": 'attachment; filename="report.bin"' });
+ return res.end(Buffer.from([0, 255, 128, 13, 10]));
+ }
+ return reply(page([{ id: "aabbccdd-0000-4000-8000-000000000001", object: "agent.session.artifact",
+ session_id: artifactMatch[1], environment_id: sessions.find((s) => s.id === artifactMatch[1])?.environment.id,
+ turn_id: "aabbccdd-0000-4000-8000-000000000002", path: "/workspace/outputs/report.bin", size_bytes: 5, created_at: now() }]));
+ }
const match = url.pathname.match(
/^\/v1\/agents\/sessions\/([^/]+)(?:\/(items|turns|events))?$/,
);
diff --git a/example/parsar/tests/product.test.mjs b/example/parsar/tests/product.test.mjs
index da1cf5ca2..7ff07a72d 100644
--- a/example/parsar/tests/product.test.mjs
+++ b/example/parsar/tests/product.test.mjs
@@ -201,7 +201,7 @@ async function setup(store, core) {
const api = productAPI(store, core);
const put = (kind, body, id = randomUUID()) =>
api("PUT", `/app/${kind}/${id}`, body);
- const provider = await put("providers", { name: "Moonshot" });
+ const provider = await put("providers", { name: "Moonshot", base_url: "https://provider.example/v1", api_key: "provider-secret" });
const model = await put("models", {
name: "Kimi",
model: "kimi-k2.6",
@@ -263,6 +263,10 @@ test("one Agent creates independent Sessions; edits do not mutate prior executio
assert.deepEqual(calls[0].body.environment.skills, [
{ type: "skill_reference", skill_id: "skill_review" },
]);
+ assert.deepEqual(calls[0].body.x_agents_core.model_provider, {
+ protocol: "anthropic", base_url: "https://provider.example/v1", api_key: "provider-secret",
+ });
+ assert.equal(JSON.stringify(first).includes("provider-secret"), false);
assert.equal(calls[0].body.agent.tools.length, 1);
const zip = unzipSync(
Buffer.from(calls[0].body.environment.plugins[0].source.data, "base64"),
@@ -435,3 +439,17 @@ test("model editing and moving retain unique IDs within each Provider", async (t
});
assert.equal(renamed.model, "three");
});
+
+
+test("MiniMax MCP bindings reject before Core for text-only and hosted placements", async (t) => {
+ const store = openStore(":memory:");
+ t.after(() => store.close());
+ let calls = 0;
+ const { put, agent } = await setup(store, async () => { calls++; return { id: randomUUID() }; });
+ const config = await put("agents", { ...agent, harness: "mcode", skill_ids: [] }, agent.id);
+ for (const environment of ["none", "openai_hosted"]) {
+ const runtime = await put("runtimes", { name: environment, environment });
+ await assert.rejects(put("sessions", { name: "Rejected", input: "hello", agent_id: config.id, runtime_id: runtime.id }), /MiniMax Code.*MCP/);
+ }
+ assert.equal(calls, 0);
+});
diff --git a/example/parsar/tests/server.test.mjs b/example/parsar/tests/server.test.mjs
index c7ffd5718..123788bce 100644
--- a/example/parsar/tests/server.test.mjs
+++ b/example/parsar/tests/server.test.mjs
@@ -160,3 +160,35 @@ test("SSE forwards chunks before completion and aborts upstream when the browser
abort.abort();
await disconnected;
});
+
+
+test("workspace uploads allow bounded inline files and artifact downloads preserve binary bytes", async (t) => {
+ const id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
+ const binary = new Uint8Array([0, 255, 128, 13, 10]);
+ let writes = 0;
+ const url = await serve(t, (target, init) => {
+ assert.equal(init.headers.Authorization, "Bearer server-only-secret");
+ if (target.endsWith("/files")) {
+ writes++;
+ assert.ok(init.body.length > 1024 * 1024);
+ return Response.json({ ok: true }, { status: 201 });
+ }
+ return new Response(binary, { headers: { "Content-Type": "text/html", "Content-Disposition": 'attachment; filename="report.bin"' } });
+ });
+ const uploaded = await fetch(`${url}/v1/agents/environments/${id}/files`, {
+ method: "POST", headers: { origin: url, "content-type": "application/json" },
+ body: JSON.stringify({ type: "inline", path: "/workspace/inputs/a", data: Buffer.alloc(1024 * 1024).toString("base64") }),
+ });
+ assert.equal(uploaded.status, 201);
+ const tooLarge = await fetch(`${url}/v1/agents/environments/${id}/files`, {
+ method: "POST", headers: { origin: url }, body: "x".repeat(8 * 1024 * 1024 + 1),
+ });
+ assert.equal(tooLarge.status, 413);
+ assert.equal(writes, 1);
+ const download = await fetch(`${url}/v1/agents/sessions/${id}/artifacts/${id}/content`);
+ assert.equal(download.headers.get("content-type"), "application/octet-stream");
+ assert.match(download.headers.get("content-disposition"), /^attachment/);
+ assert.deepEqual(new Uint8Array(await download.arrayBuffer()), binary);
+ const denied = await fetch(`${url}/v1/agents/sessions/${id}/artifacts/${id}/content`, { headers: { origin: "https://other.example" } });
+ assert.equal(denied.status, 403);
+});
diff --git a/example/parsar/tests/workflow.spec.ts b/example/parsar/tests/workflow.spec.ts
index 2aed9b837..d1e79db0f 100644
--- a/example/parsar/tests/workflow.spec.ts
+++ b/example/parsar/tests/workflow.spec.ts
@@ -15,6 +15,11 @@ async function resources(page: Page) {
await page.getByLabel("kimi-k2.6", { exact: true }).check();
await page.getByRole("button", { name: "保存", exact: true }).click();
await expect(page.getByRole("dialog")).toHaveCount(0);
+ await page.getByRole("button", { name: "编辑 Provider Moonshot" }).click();
+ await page.getByLabel("Base URL").fill("https://provider.example/v1");
+ await page.getByRole("button", { name: "手动选择", exact: true }).click();
+ await page.getByRole("button", { name: "保存", exact: true }).click();
+ await expect(page.getByRole("dialog")).toHaveCount(0);
await page
.getByRole("button", { name: "编辑 kimi-k2.6", exact: true })
.click();
@@ -294,6 +299,7 @@ test("Provider groups contain multiple models and retain manual names", async ({
await resources(page);
await navigate(page, "模型");
await page.getByRole("button", { name: "编辑 Provider Moonshot" }).click();
+ await page.getByLabel("Base URL").fill("http://127.0.0.1:18181");
await page.getByRole("button", { name: "获取模型", exact: true }).click();
await expect(page.getByLabel("kimi-k2.6", { exact: true })).toBeChecked();
await page.getByLabel("kimi-k2", { exact: true }).check();
@@ -456,3 +462,98 @@ test("an active reply without a replayed item baseline refreshes immediately on
.filter({ hasText: "断线前,断线后仍在生成" }),
).toBeVisible({ timeout: 1500 });
});
+
+
+test("Session uploads insert a file path and download binary artifacts without leaving the conversation", async ({ page }) => {
+ await page.goto("/");
+ await resources(page);
+ await agent(page);
+ await page.getByRole("link", { name: "打开", exact: true }).click();
+ await session(page);
+ await expect(page.getByLabel("继续对话")).toBeVisible();
+ await page.getByRole("button", { name: "文件", exact: true }).click();
+ await page.getByLabel("选择上传文件").setInputFiles({ name: "large.bin", mimeType: "application/octet-stream", buffer: Buffer.alloc(5 * 1024 * 1024 + 1) });
+ await expect(page.getByRole("alert")).toContainText("文件不能超过 5 MiB");
+ await page.getByLabel("选择上传文件").setInputFiles({ name: "numbers.csv", mimeType: "text/csv", buffer: Buffer.from("a,b\n1,2") });
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ await expect(page.getByLabel("继续对话")).toHaveValue(/请处理文件:\.\/inputs\/.*numbers.csv/);
+ const draft = await page.getByLabel("继续对话").inputValue();
+ const originalSessionURL = page.url();
+ await page.reload();
+ await expect(page.getByLabel("继续对话")).toHaveValue(draft);
+ await page.getByRole("link", { name: "返回 Agent" }).click();
+ await session(page, "第二个文件会话");
+ await expect(page.getByLabel("继续对话")).toHaveValue("");
+ await page.goto(originalSessionURL);
+ await expect(page.getByLabel("继续对话")).toHaveValue(draft);
+ await page.getByRole("button", { name: "文件", exact: true }).click();
+ const contentRoute = "**/artifacts/*/content";
+ await page.route(contentRoute, (route) => route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: { message: "文件暂不可用" } }) }));
+ await page.getByRole("button", { name: "下载 report.bin" }).click();
+ await expect(page.getByRole("alert")).toContainText("文件暂不可用");
+ await page.unroute(contentRoute);
+ const downloading = page.waitForEvent("download");
+ await page.getByRole("button", { name: "下载 report.bin" }).click();
+ const download = await downloading;
+ const stream = await download.createReadStream();
+ const chunks = [];
+ for await (const chunk of stream!) chunks.push(chunk);
+ expect(Buffer.concat(chunks)).toEqual(Buffer.from([0, 255, 128, 13, 10]));
+ await page.getByRole("button", { name: "Close", exact: true }).click();
+ await expect(page.getByLabel("继续对话")).toHaveValue(/numbers.csv/);
+ await page.route(/\/v1\/agents\/sessions\/[a-f0-9-]+$/, async (route) => {
+ const response = await route.fetch();
+ const value = await response.json();
+ await route.fulfill({ json: { ...value, status: "failed", error: "运行环境准备失败,请检查配置。" } });
+ });
+ await expect(page.getByRole("alert")).toContainText("运行环境准备失败,请检查配置。");
+});
+
+
+test("unsupported runtime bindings are explained before Session creation", async ({ page, request }) => {
+ await resources(page);
+ await agent(page);
+ const runtime = await request.put("http://127.0.0.1:18180/app/runtimes/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", {
+ headers: { origin: "http://127.0.0.1:18180" },
+ data: { name: "用户机器", environment: "self_hosted", platform: "linux", workspace_directory: "/home/user/project" },
+ });
+ expect(runtime.ok()).toBe(true);
+ await page.getByRole("link", { name: "打开", exact: true }).click();
+ await page.getByRole("button", { name: "开始会话", exact: true }).click();
+ await page.getByLabel("会话名称").fill("Blocked");
+ await page.getByLabel("运行时", { exact: true }).click();
+ await page.getByRole("option", { name: "用户机器", exact: true }).click();
+ await expect(page.getByRole("alert")).toContainText("不能绑定托管 Skill");
+ await expect(page.getByRole("button", { name: "开始", exact: true })).toBeDisabled();
+ const counts = await (await request.get("http://127.0.0.1:18181/counts")).json();
+ expect(counts.sessions).toHaveLength(0);
+});
+
+
+test("a send completed after leaving the Session does not restore a sent draft", async ({ page }) => {
+ await resources(page);
+ await agent(page);
+ await page.getByRole("link", { name: "打开", exact: true }).click();
+ await session(page);
+ await expect(page.getByLabel("继续对话")).toBeVisible();
+ const sessionURL = page.url();
+ let release!: () => void;
+ let arrived!: () => void;
+ const gate = new Promise((resolve) => { release = resolve; });
+ const received = new Promise((resolve) => { arrived = resolve; });
+ await page.route("**/events", async (route) => {
+ if (route.request().method() !== "POST") return route.continue();
+ const response = await route.fetch();
+ arrived();
+ await gate;
+ await route.fulfill({ response });
+ });
+ await page.getByLabel("继续对话").fill("Stream reply");
+ await page.getByRole("button", { name: "发送", exact: true }).click();
+ await received;
+ await page.getByRole("link", { name: "返回 Agent" }).click();
+ release();
+ await expect.poll(() => page.evaluate(() => Object.keys(sessionStorage).filter((key) => key.startsWith("oac-example-message-")).length)).toBe(0);
+ await page.goto(sessionURL);
+ await expect(page.getByLabel("继续对话")).toHaveValue("");
+});
From 4d6ba566931c81104651359aef64260f1e131184 Mon Sep 17 00:00:00 2001
From: saladday <1203511142@qq.com>
Date: Wed, 30 Sep 2026 14:51:28 +0800
Subject: [PATCH 3/6] docs(example): remove external product provenance
narrative
---
CONTRIBUTING.md | 5 +++--
example/parsar/README.md | 14 ++------------
2 files changed, 5 insertions(+), 14 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ba2573a23..a5396d139 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -152,14 +152,15 @@ databases, credentials and migrations. The product uses Core exclusively; it has
### Optional application example
-`example/parsar/` is an optional, independently started Agent workbench. Its
+`example/parsar/` is an optional, independently started Agent workbench maintained
+in this repository, with no dependency on the external Parsar product repository. Its
[README](example/parsar/README.md) owns its product behavior. The boundary rules are:
- It calls only public `/v1` APIs. Its Project key stays server-side; it never
holds a Core key or issues machine credentials. Self-hosted connection displays
the public Session installation command unchanged; Core owns bootstrap
authorization and machine credential issuance.
-- It may reuse product UI and keep a small product-owned SQLite database (Node's
+- It may keep a small product-owned SQLite database (Node's
built-in module, Node 22.13+), outside the checkout and isolated by Core origin
and Project key fingerprint. Provider keys never reach the browser.
- Core owns Skills and all execution and history state. The example stores only
diff --git a/example/parsar/README.md b/example/parsar/README.md
index 406f398c1..378bffcc6 100644
--- a/example/parsar/README.md
+++ b/example/parsar/README.md
@@ -1,6 +1,6 @@
# Parsar Agent workbench
-A small product application on OpenAgentCore, using Parsar UI. Manage models,
+An independently started small application on OpenAgentCore. Manage models,
Skills, HTTP MCP services and runtime configurations; compose reusable Agent
configurations and start independent Sessions. Continue the same Session to keep
its history and live workspace. There is no task layer or shared workspace.
@@ -80,7 +80,7 @@ model availability or MCP connectivity. Skills require a hosted runtime.
The loopback Node server has two small responsibilities: a fixed allowlist proxy
for public Core resources, and `/app/` CRUD for product-owned configuration. One
SQLite table stores typed JSON records. There is no ORM, background scheduler,
-execution database or imported Parsar backend.
+execution database.
SQLite files live in `~/.oac/data/parsar-example/`. Set the absolute
`OAC_EXAMPLE_DATA_DIR` to relocate them. A hash of the Core origin and Project key
@@ -149,16 +149,6 @@ checks Skill/Plugin installation, same-Session file reuse, and workspace isolati
between two Sessions. It asks the model to call DeepWiki; inspect the recorded
tool activity to distinguish a successful call from an attempted call.
-## UI provenance
-
-The copied UI primitives, `src/lib/utils.ts`, `src/style.css`
-and `public/*` originate in [Parsar](https://github.com/MiniMax-AI-Dev/parsar)
-revision `90fafede`, under [MIT](LICENSE). The responsive body minimum width and
-application pages are local adaptations. Navigation animation adapts
-[Motion Primitives](https://github.com/ibelick/motion-primitives), with reduced-motion
-support and its [MIT notice](MOTION-PRIMITIVES-LICENSE). Multica's resource and
-Agent organization informs the product flow; no Multica code is copied.
-
## Connect a user machine
Create a user-machine runtime and start a Session with an Agent using Codex or
From 25591c1c768f1ee600235c1250787e54696db0ad Mon Sep 17 00:00:00 2001
From: saladday <1203511142@qq.com>
Date: Wed, 30 Sep 2026 14:59:59 +0800
Subject: [PATCH 4/6] fix(example): handle slow uploads and UTF-8 filenames
---
example/parsar/server.mjs | 7 ++++++-
example/parsar/src/SessionFiles.tsx | 30 ++++++++++++++++++++-------
example/parsar/tests/server.test.mjs | 17 +++++++++++++++
example/parsar/tests/workflow.spec.ts | 7 +++++++
4 files changed, 52 insertions(+), 9 deletions(-)
diff --git a/example/parsar/server.mjs b/example/parsar/server.mjs
index ff98aacd6..bf81f97e6 100644
--- a/example/parsar/server.mjs
+++ b/example/parsar/server.mjs
@@ -195,7 +195,12 @@ export function createHandler(
const streaming =
req.method === "GET" && url.pathname.endsWith("/events");
const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), 30_000);
+ const fileWrite =
+ req.method === "POST" && url.pathname.endsWith("/files");
+ const timer = setTimeout(
+ () => controller.abort(),
+ fileWrite ? 240_000 : 30_000,
+ );
res.on("close", () => controller.abort());
try {
const chunks = [];
diff --git a/example/parsar/src/SessionFiles.tsx b/example/parsar/src/SessionFiles.tsx
index 8a6c970ae..d0d1606cf 100644
--- a/example/parsar/src/SessionFiles.tsx
+++ b/example/parsar/src/SessionFiles.tsx
@@ -16,9 +16,16 @@ const maxUploadBytes = 5 * 1024 * 1024;
async function upload(environmentId: string, file: File) {
if (file.size > maxUploadBytes) throw new Error("文件不能超过 5 MiB。");
- const name =
- file.name.replace(/[\\/:*?"<>|\x00-\x1f\x7f]/g, "_").slice(0, 100) ||
- "file";
+ let name = "";
+ const encoder = new TextEncoder();
+ for (const character of file.name.replace(
+ /[\\/:*?"<>|\x00-\x1f\x7f]/g,
+ "_",
+ )) {
+ if (encoder.encode(name + character).length > 200) break;
+ name += character;
+ }
+ name ||= "file";
const path = `/workspace/inputs/${crypto.randomUUID()}-${name}`;
const data = await new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -26,11 +33,18 @@ async function upload(environmentId: string, file: File) {
reader.onerror = () => reject(new Error("无法读取文件,请重新选择。"));
reader.readAsDataURL(file);
});
- await api.createEnvironmentFile(environmentId, {
- type: "inline",
- path,
- data,
- });
+ try {
+ await api.createEnvironmentFile(environmentId, {
+ type: "inline",
+ path,
+ data,
+ });
+ } catch (error) {
+ const reason = error instanceof Error ? error.message : "请求失败";
+ throw new Error(
+ `${reason} 上传未确认,文件可能已写入 ${path.replace(/^\/workspace\//, "./")}。请先检查该路径再重新上传。`,
+ );
+ }
return path;
}
diff --git a/example/parsar/tests/server.test.mjs b/example/parsar/tests/server.test.mjs
index 123788bce..ba6d00461 100644
--- a/example/parsar/tests/server.test.mjs
+++ b/example/parsar/tests/server.test.mjs
@@ -192,3 +192,20 @@ test("workspace uploads allow bounded inline files and artifact downloads preser
const denied = await fetch(`${url}/v1/agents/sessions/${id}/artifacts/${id}/content`, { headers: { origin: "https://other.example" } });
assert.equal(denied.status, 403);
});
+
+test("file writes can finish beyond the ordinary proxy timeout", async (t) => {
+ const url = await serve(t, async (_target, init) => {
+ await new Promise((resolve) => setTimeout(resolve, 31_000));
+ assert.equal(init.signal.aborted, false);
+ return Response.json({ path: "/workspace/inputs/file.txt" });
+ });
+ const response = await fetch(
+ `${url}/v1/agents/environments/00000000-0000-4000-8000-000000000001/files`,
+ {
+ method: "POST",
+ headers: { origin: url },
+ body: "{}",
+ },
+ );
+ assert.equal(response.status, 200);
+});
diff --git a/example/parsar/tests/workflow.spec.ts b/example/parsar/tests/workflow.spec.ts
index d1e79db0f..01ceb1cda 100644
--- a/example/parsar/tests/workflow.spec.ts
+++ b/example/parsar/tests/workflow.spec.ts
@@ -477,6 +477,13 @@ test("Session uploads insert a file path and download binary artifacts without l
await page.getByLabel("选择上传文件").setInputFiles({ name: "numbers.csv", mimeType: "text/csv", buffer: Buffer.from("a,b\n1,2") });
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.getByLabel("继续对话")).toHaveValue(/请处理文件:\.\/inputs\/.*numbers.csv/);
+ await page.getByRole("button", { name: "文件", exact: true }).click();
+ const uploaded = page.waitForRequest((request) => request.method() === "POST" && request.url().endsWith("/files"));
+ await page.getByLabel("选择上传文件").setInputFiles({ name: "中".repeat(80) + ".txt", mimeType: "text/plain", buffer: Buffer.from("small") });
+ const uploadedPath = (await uploaded).postDataJSON().path;
+ expect(Buffer.byteLength(uploadedPath.split("/").at(-1), "utf8")).toBeLessThanOrEqual(255);
+ expect(uploadedPath).not.toContain("�");
+ await expect(page.getByRole("dialog")).toHaveCount(0);
const draft = await page.getByLabel("继续对话").inputValue();
const originalSessionURL = page.url();
await page.reload();
From 2de7c0393db82da5349e638ad52fe6e64ea58fcc Mon Sep 17 00:00:00 2001
From: saladday <1203511142@qq.com>
Date: Wed, 30 Sep 2026 15:42:49 +0800
Subject: [PATCH 5/6] docs: preserve example boundary list formatting
---
CONTRIBUTING.md | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1dbcec308..8ed3c2574 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -50,7 +50,9 @@ Agents API is the primary infrastructure deliverable. Parsar is an ordinary clie
`example/parsar/` is an optional, independently started Agent workbench maintained in this repository, with no dependency on the external Parsar product repository. Its [README](example/parsar/README.md) owns its product behavior. The boundary rules are:
-- It calls only public `/v1` APIs. Its Project key stays server-side; it never holds a Core key or issues machine credentials. Self-hosted connection displays the public Session installation command unchanged; Core owns bootstrap authorization and machine credential issuance. - It may keep a small product-owned SQLite database (Node's built-in module, Node 22.13+), outside the checkout and isolated by Core origin and Project key fingerprint. Provider keys never reach the browser. - Core owns Skills and all execution and history state. The example stores only Session references and pending creation requests with stable idempotency keys.
+- It calls only public `/v1` APIs. Its Project key stays server-side; it never holds a Core key or issues machine credentials. Self-hosted connection displays the public Session installation command unchanged; Core owns bootstrap authorization and machine credential issuance.
+- It may keep a small product-owned SQLite database (Node's built-in module, Node 22.13+), outside the checkout and isolated by Core origin and Project key fingerprint. Provider keys never reach the browser.
+- Core owns Skills and all execution and history state. The example stores only Session references and pending creation requests with stable idempotency keys.
- Product resources use `/app/` and never become Core API or database conventions.
- It is excluded from Core distributions and cannot become a service dependency.
From 570f7df1bbb6658d48c46f8b2f8a7bfa27a6190f Mon Sep 17 00:00:00 2001
From: saladday <1203511142@qq.com>
Date: Wed, 30 Sep 2026 16:08:02 +0800
Subject: [PATCH 6/6] fix(example): use renamed Core client package
---
example/parsar/src/SessionFiles.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/example/parsar/src/SessionFiles.tsx b/example/parsar/src/SessionFiles.tsx
index d0d1606cf..af7da853b 100644
--- a/example/parsar/src/SessionFiles.tsx
+++ b/example/parsar/src/SessionFiles.tsx
@@ -1,7 +1,7 @@
import { useRef } from "react";
import { useInfiniteQuery, useMutation } from "@tanstack/react-query";
import { Upload, Download } from "lucide-react";
-import type { ListPage, SessionArtifact } from "@agents-core-web/agents-client";
+import type { ListPage, SessionArtifact } from "@oac/agents-client";
import { api, dateTime } from "./lib/api";
import { Button } from "./components/ui/button";
import {