diff --git a/components/canvas/chat-panel/canvas-agent-run-panel.tsx b/components/canvas/chat-panel/canvas-agent-run-panel.tsx
index 04c5727e9..a25046c5b 100644
--- a/components/canvas/chat-panel/canvas-agent-run-panel.tsx
+++ b/components/canvas/chat-panel/canvas-agent-run-panel.tsx
@@ -140,7 +140,7 @@ export function CanvasAgentRunPanel(props: {
) : null}
- {run.runtime?.engine === "zcode" ? ZCode · {run.runtime.state === "starting" ? "正在启动独立沙箱" : "独立沙箱"} : null}
+ {run.runtime?.engine === "zcode" ? ZCode · {!run.runtime.sandbox ? "尚未启动沙箱" : run.runtime.state === "starting" ? "正在启动独立沙箱" : run.runtime.state === "running" ? "独立沙箱运行中" : "沙箱已停止"} : null}
已处理 {run.steps} 轮 · {run.toolCount} 个工具步骤
{run.inputs.slice(-2).map((input) => (
diff --git a/docs/specs/zcode-canvas/authorization-audit.json b/docs/specs/zcode-canvas/authorization-audit.json
index 22aadd968..fe1009f2a 100644
--- a/docs/specs/zcode-canvas/authorization-audit.json
+++ b/docs/specs/zcode-canvas/authorization-audit.json
@@ -1,4 +1,32 @@
{
+ "legacyAliases": {
+ "getWeather": "getWeather",
+ "createDocument": "createDocument",
+ "updateDocument": "updateDocument",
+ "requestSuggestions": "requestSuggestions",
+ "generateVideo": "paused:video-authorization",
+ "respondDirect": "native-final-response",
+ "resolveTargetImage": "readCanvas",
+ "analyzeTargetImage": "analyzeCanvasImages",
+ "analyzeImage": "analyzeCanvasImages",
+ "generateNewImage": "generateCanvasImages",
+ "generateImage": "generateCanvasImages",
+ "editTargetImage": "editTargetImage",
+ "editImage": "editTargetImage",
+ "publishResultToCanvas": "editCanvasNodes",
+ "publishToCanvas": "editCanvasNodes",
+ "removeBackground": "removeBackground",
+ "removeWatermark": "removeWatermark",
+ "enhanceImage": "enhanceImage",
+ "extendImage": "extendImage",
+ "resizeImage": "resizeImage",
+ "angleEditImage": "angleEditImage",
+ "localEditImage": "localEditImage",
+ "boxCutout": "boxCutout",
+ "useSelectedCanvasImage": "readCanvas",
+ "useLatestResultImage": "readCanvas",
+ "useOriginalUploadImage": "readCanvas"
+ },
"policy": "图片画布及非视频项目业务已授权;视频画布与分镜暂不授权;底层用户权限与服务开关仍生效。管理、密钥、支付、身份认证、系统任务和Claw不属于创作Agent工具。",
"upstreamCommit": "0a1fc56d279fd1001f78387bd81aec1c26767411",
"rows": [
diff --git a/lib/ai/canvas-agent-runtime/capability-access.ts b/lib/ai/canvas-agent-runtime/capability-access.ts
index 03f467e79..504d6026c 100644
--- a/lib/ai/canvas-agent-runtime/capability-access.ts
+++ b/lib/ai/canvas-agent-runtime/capability-access.ts
@@ -26,7 +26,7 @@ export function summarizeAgentCapabilities(items: AgentCapabilityAccess[]) {
authorized,
execution,
...(authorized && execution === "server" ? { entry } : {}),
- ...(execution === "backend_required" || !authorized ? { note } : {}),
+ ...(!authorized ? { note: "暂未授权" } : execution === "backend_required" ? { note } : {}),
}));
}
export const VIDEO_CANVAS_EXTRA_ACTIONS = [
diff --git a/lib/ai/canvas-agent-runtime/legacy-tool-map.ts b/lib/ai/canvas-agent-runtime/legacy-tool-map.ts
new file mode 100644
index 000000000..98d8bd275
--- /dev/null
+++ b/lib/ai/canvas-agent-runtime/legacy-tool-map.ts
@@ -0,0 +1,10 @@
+/** Legacy planner names map to actual native MCP operations, not another planner loop. */
+export const LEGACY_AGENT_TOOL_MAP: Record = {
+ getWeather:"getWeather", createDocument:"createDocument", updateDocument:"updateDocument", requestSuggestions:"requestSuggestions",
+ generateVideo:"paused:video-authorization", respondDirect:"native-final-response", resolveTargetImage:"readCanvas",
+ analyzeTargetImage:"analyzeCanvasImages", analyzeImage:"analyzeCanvasImages", generateNewImage:"generateCanvasImages", generateImage:"generateCanvasImages",
+ editTargetImage:"editTargetImage", editImage:"editTargetImage", publishResultToCanvas:"editCanvasNodes", publishToCanvas:"editCanvasNodes",
+ removeBackground:"removeBackground", removeWatermark:"removeWatermark", enhanceImage:"enhanceImage", extendImage:"extendImage", resizeImage:"resizeImage",
+ angleEditImage:"angleEditImage", localEditImage:"localEditImage", boxCutout:"boxCutout", useSelectedCanvasImage:"readCanvas",
+ useLatestResultImage:"readCanvas", useOriginalUploadImage:"readCanvas",
+};
diff --git a/lib/ai/canvas-agent-runtime/project-tools.ts b/lib/ai/canvas-agent-runtime/project-tools.ts
index 61291eaa6..dac978264 100644
--- a/lib/ai/canvas-agent-runtime/project-tools.ts
+++ b/lib/ai/canvas-agent-runtime/project-tools.ts
@@ -1,7 +1,9 @@
import "server-only";
+import { AgentToolRejectedError } from "./protocol";
import { tool, type ToolSet } from "ai";
import type { Session } from "next-auth";
import { z } from "zod";
+import { LEGACY_AGENT_TOOL_MAP } from "./legacy-tool-map";
import contracts from "./project-operation-contracts.json";
import { getAccessibleAgentPresetById, getDocumentById } from "@/lib/db/queries";
import { getEnabledTools } from "@/lib/ai/tools-registry";
@@ -57,6 +59,7 @@ export async function registerProjectAgentTools(params: {
if (!response.headers.get("content-type")?.includes("application/json")) { await response.body?.cancel(); throw new Error(`此工具未返回可核验JSON结果(${response.status});请使用真实界面处理文件或流式操作`); }
const raw = await response.text(); if (raw.length > 2 * 1024 * 1024) throw new Error("业务返回过大,请缩小查询范围");
const payload = JSON.parse(raw);
+ if ([400,401,403,404,405,422].includes(response.status)) throw new AgentToolRejectedError(`业务工具参数或权限未通过(${response.status}),未提交操作。${typeof payload?.error === "string" ? payload.error.slice(0,200) : "请先读取该操作的真实请求契约"}`);
if (!response.ok || payload?.success === false || payload?.error) throw new Error(`业务工具 ${operation.title} 未成功(${response.status}):${typeof payload?.error === "string" ? payload.error.slice(0, 200) : "请核对输入参数或底层服务权限"}`);
if (operation.id === "canvas-list.get" && Array.isArray(payload?.data)) payload.data = payload.data.filter((row: { workspaceType?: string }) => row.workspaceType !== "video");
const status = payload?.processing || ["pending", "running", "queued"].includes(payload?.status) ? "pending" : payload?.status === "failed" ? "failed" : "succeeded";
@@ -69,7 +72,7 @@ export async function registerProjectAgentTools(params: {
const preset = await getAccessibleAgentPresetById({ id: String(input.presetId), userId });
if (!preset || preset.status !== "active") throw new Error("此技能不可用或无权访问");
if (preset.type === "video") throw new Error("视频技能暂未授权给 ZCode Agent");
- return { id: preset.id, name: preset.name, type: preset.type, promptTemplate: preset.promptTemplate, defaultModelId: preset.defaultModelId,
+ return { toolAliases: LEGACY_AGENT_TOOL_MAP, id: preset.id, name: preset.name, type: preset.type, promptTemplate: preset.promptTemplate, defaultModelId: preset.defaultModelId,
defaultParams: sanitizeProjectToolOutput(preset.defaultParams), slots: preset.slots.map(slot => ({ title: slot.title, promptTemplate: slot.promptTemplate, defaultParams: sanitizeProjectToolOutput(slot.defaultParams) })),
message: "请按用户当前任务采用技能指令,并使用真实已授权工具执行;不得虚构结果。" };
});
diff --git a/lib/ai/canvas-agent-runtime/protocol.ts b/lib/ai/canvas-agent-runtime/protocol.ts
index 03fa46a88..3fd7f6723 100644
--- a/lib/ai/canvas-agent-runtime/protocol.ts
+++ b/lib/ai/canvas-agent-runtime/protocol.ts
@@ -7,10 +7,13 @@ export type CanvasAgentRunStatus =
| "needs_review"
| "interrupted";
export class AgentRunControlChangedError extends Error {}
+/** Server confirms validation/auth rejection before a business operation starts. */
+export class AgentToolRejectedError extends Error {}
export type AgentCall = { id: string; name: string; input: Record };
export type AgentCallRecord = AgentCall & {
status: "queued" | "started" | "succeeded" | "failed";
uncertain?: boolean;
+ rejected?: boolean;
output?: Record;
error?: string;
};
diff --git a/lib/ai/zcode-agent/task.ts b/lib/ai/zcode-agent/task.ts
index 9d536ac32..77ae24b63 100644
--- a/lib/ai/zcode-agent/task.ts
+++ b/lib/ai/zcode-agent/task.ts
@@ -3,7 +3,7 @@ import { asSchema, type ToolSet } from "ai";
import { join } from "node:path";
import { runNativeZcode, type NativeEvent, type NativeResult } from "@/services/zcode-agent/runner.mjs";
import type { CanvasAgentTool } from "@/lib/ai/canvas-agent-runtime/loop";
-import { publicAgentError, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol";
+import { AgentToolRejectedError, publicAgentError, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol";
type Ports = {
native?: typeof runNativeZcode;
@@ -139,7 +139,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise {
const call = snapshot.calls.find(call => call.id === callId)!;
- call.status = "failed"; call.error = publicAgentError(error); call.uncertain = !entry.readOnly;
+ call.status = "failed"; call.error = publicAgentError(error); call.rejected = error instanceof AgentToolRejectedError; call.uncertain = !entry.readOnly && !call.rejected;
await save(call.error);
});
throw error;
@@ -155,28 +155,30 @@ export async function runZcodeCanvasTask(ports: Ports): Promise call.status === "failed" || call.uncertain))
+ if (snapshot.calls.some(call => (call.status === "failed" && !call.rejected) || call.uncertain))
return await save("部分工具结果待确认,已保留执行记录,请核对后继续。", "needs_review");
if (question || snapshot.calls.some(call => call.output?.mode === "interactive"))
return await save(question || "请在已打开的工具面板中完成操作,然后继续任务。", "needs_review");
const supplements = snapshot.inputs.filter(input => !startedInputs.has(input.id));
if (supplements.length) { prompt = supplements.map(input => input.text).join("\n"); continue; }
- const projectWrites = snapshot.calls.filter(call => !ports.tools[call.name]?.readOnly && call.output?.projectService);
- const canvasWrites = snapshot.calls.filter(call => !ports.tools[call.name]?.readOnly && !call.output?.projectService);
+ const projectWrites = snapshot.calls.filter(call => !call.rejected && !ports.tools[call.name]?.readOnly && call.output?.projectService);
+ const canvasWrites = snapshot.calls.filter(call => !call.rejected && !ports.tools[call.name]?.readOnly && !call.output?.projectService);
if (projectWrites.length && !canvasWrites.length && !snapshot.requirements?.expectedImageCount) {
const pending = projectWrites.some(call => call.output?.status !== "succeeded");
return await save(pending ? "业务任务已受理或等待核对,请读取实际任务状态,保留原任务编号继续。" : result.response || "项目业务操作已完成。", pending ? "needs_review" : "completed");
}
// Text/analysis tasks have no generated media to inspect. Mutating tasks
// still use the existing independent checks against actual saved nodes.
- const mutated = snapshot.calls.some(call => !ports.tools[call.name]?.readOnly);
+ const mutated = snapshot.calls.some(call => !call.rejected && !ports.tools[call.name]?.readOnly);
if (!mutated && !snapshot.requirements?.expectedImageCount) {
const expectsChange = /生成|抠图|去背景|换背景|删除|移除|替换|改名|移动|放大|裁剪|旋转|导出|下载|制作|添加|上传/.test(snapshot.objective)
&& !/只(?:要|需)?(?:分析|讨论|建议|提示词)|不要(?:执行|生成|修改)/.test(snapshot.objective);
return await save(expectsChange ? "尚未执行画布操作,请核对任务计划后继续。" : result.response || "已完成画布分析。", expectsChange ? "needs_review" : "completed");
}
await save("正在核对画布中的实际结果");
- const verification = await ports.verify(snapshot); snapshot.verification = verification;
+ const reviewSnapshot = { ...snapshot, calls: snapshot.calls.filter(call => !call.rejected) };
+ const verification = await ports.verify(reviewSnapshot); snapshot.verification = verification;
+ snapshot.inputTokens = reviewSnapshot.inputTokens; snapshot.outputTokens = reviewSnapshot.outputTokens;
if (verification.passed) return await save(result.response || "任务完成,结果已写入画布。", "completed");
if (verification.retryable && snapshot.repairs < 2) {
snapshot.repairs++;
diff --git a/scripts/zcode-agent/audit-project-tools.mjs b/scripts/zcode-agent/audit-project-tools.mjs
index 434072259..28abc4ea4 100644
--- a/scripts/zcode-agent/audit-project-tools.mjs
+++ b/scripts/zcode-agent/audit-project-tools.mjs
@@ -31,7 +31,10 @@ console.log(JSON.stringify({ operations: catalog.length, routeContracts: Object.
const canvas = JSON.parse(execFileSync(process.execPath, ['node_modules/tsx/dist/cli.mjs','-e', "import {getCanvasAgentAccessManifest} from './lib/ai/canvas-agent-runtime/capability-access';console.log(JSON.stringify(getCanvasAgentAccessManifest()))"], { encoding: 'utf8' }));
const project = catalog.map(item=>({...item, authorized:item.group !== '分镜任务', previouslyExposed:false, execution:'server'}));
const utilities = ['getWeather','createDocument','updateDocument','requestSuggestions','loadProjectSkill'].map(id=>({id,title:id,group:'文档、天气与技能',authorized:true,previouslyExposed:false,entry:id,execution:'server'}));
-const report = { policy:'图片画布及非视频项目业务已授权;视频画布与分镜暂不授权;底层用户权限与服务开关仍生效。管理、密钥、支付、身份认证、系统任务和Claw不属于创作Agent工具。',
+const legacyAliases = JSON.parse(execFileSync(process.execPath, ['node_modules/tsx/dist/cli.mjs','-e', "import {LEGACY_AGENT_TOOL_MAP} from './lib/ai/canvas-agent-runtime/legacy-tool-map';console.log(JSON.stringify(LEGACY_AGENT_TOOL_MAP))"], { encoding: 'utf8' }));
+const legacyNames = [...(await readFile('lib/ai/tools-registry.ts','utf8')).matchAll(/name:\s*"([^"]+)"/g)].map(match=>match[1]);
+if (legacyNames.some(name=>!legacyAliases[name])) throw new Error('Legacy tool missing from authorization audit');
+const report = { legacyAliases, policy:'图片画布及非视频项目业务已授权;视频画布与分镜暂不授权;底层用户权限与服务开关仍生效。管理、密钥、支付、身份认证、系统任务和Claw不属于创作Agent工具。',
upstreamCommit:'0a1fc56d279fd1001f78387bd81aec1c26767411', rows:[...canvas,...project,...utilities],
verifiedRoutes:Object.keys(contracts).length, deploymentOnly:['smart-edit.get'] };
await writeFile('docs/specs/zcode-canvas/authorization-audit.json',JSON.stringify(report,null,2)+'\n');
diff --git a/scripts/zcode-agent/task-fixture.ts b/scripts/zcode-agent/task-fixture.ts
index 5a45380ac..968e58606 100644
--- a/scripts/zcode-agent/task-fixture.ts
+++ b/scripts/zcode-agent/task-fixture.ts
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import { tool } from 'ai';
import { z } from 'zod';
import { runZcodeCanvasTask } from '../../lib/ai/zcode-agent/task';
-import { newAgentRunSnapshot } from '../../lib/ai/canvas-agent-runtime/protocol';
+import { AgentToolRejectedError, newAgentRunSnapshot } from '../../lib/ai/canvas-agent-runtime/protocol';
import type { runNativeZcode } from '../../services/zcode-agent/runner.mjs';
type NativeOptions = Parameters[0];
function fixture(objective = '修改图片') {
@@ -64,6 +64,14 @@ let checks = 0;
const h = fixture(); h.ports.isCancelled = async () => true;
assert.equal(await runZcodeCanvasTask(h.ports), 'cancelled'); assert.equal(h.writes(), 1); checks++;
}
+{
+ const h = fixture(); let attempts = 0;
+ h.ports.tools.edit.execute = async () => { attempts++; if (attempts === 1) throw new AgentToolRejectedError('参数未通过,未提交'); return { updatedNodeIds: ['a'] }; };
+ h.ports.native = async options => { await options.execute('planCanvasTask', {}, 'plan', options.signal!);
+ await assert.rejects(options.execute('edit', { nodeIds: ['a'] }, 'rejected', options.signal!));
+ await options.execute('edit', { nodeIds: ['a'] }, 'corrected', options.signal!); return result; };
+ assert.equal(await runZcodeCanvasTask(h.ports), 'completed'); assert.equal(h.snapshot.calls.find(c=>c.id==='rejected')?.uncertain,false); checks++;
+}
console.log('NATIVE_TASK_ADAPTER_PASSED', { checks });
}
diff --git a/tests/routes/agent-capability-access.test.ts b/tests/routes/agent-capability-access.test.ts
index d32dcbac3..0c152f115 100644
--- a/tests/routes/agent-capability-access.test.ts
+++ b/tests/routes/agent-capability-access.test.ts
@@ -27,7 +27,7 @@ test("full capability discovery leaves room for subsequent edits without losing
const manifest = getCanvasAgentAccessManifest();
const summaries = summarizeAgentCapabilities(manifest);
expect(summaries.map((item) => item.id)).toEqual(manifest.map((item) => item.id));
- for (const item of manifest.filter((entry) => entry.execution === "backend_required"))
+ for (const item of manifest.filter((entry) => entry.execution === "backend_required" && entry.authorized))
expect(summaries.find((entry) => entry.id === item.id)?.note).toBe(item.note);
expect(
estimateContextTokens([{ role: "user", content: JSON.stringify(summaries) }]),
diff --git a/tests/routes/canvas-agent-project-tools.test.ts b/tests/routes/canvas-agent-project-tools.test.ts
index 6eed25f4f..d4264c3d1 100644
--- a/tests/routes/canvas-agent-project-tools.test.ts
+++ b/tests/routes/canvas-agent-project-tools.test.ts
@@ -1,3 +1,4 @@
+import { LEGACY_AGENT_TOOL_MAP } from "@/lib/ai/canvas-agent-runtime/legacy-tool-map";
import { expect, test } from "@playwright/test";
import { readFileSync, existsSync } from "node:fs";
import { PROJECT_AGENT_OPERATIONS, projectOperationAuthorized, projectOperationUrl, sanitizeProjectToolOutput } from "@/lib/ai/canvas-agent-runtime/project-operation-catalog";
@@ -30,3 +31,10 @@ test("operation URL substitution rejects path escapes and keeps query values enc
test("nested model/storage secrets cannot be returned to the native model", () => {
expect(sanitizeProjectToolOutput({ data:[{ id:'m',apiKey:'secret', provider:{ SK:'hidden',AK:'hidden',secretAccessKey:'hidden',baseUrl:'https://safe' } }], headers:{ authorization:'secret' } })).toEqual({data:[{id:'m',provider:{baseUrl:'https://safe'}}]});
});
+
+test("every legacy planner tool has a concrete native counterpart or explicit video pause", async () => {
+ const names=[...readFileSync('lib/ai/tools-registry.ts','utf8').matchAll(/name:\s*"([^"]+)"/g)].map(match=>match[1]);
+ expect(names).toHaveLength(26);
+ for (const name of names) expect(LEGACY_AGENT_TOOL_MAP[name],name).toBeTruthy();
+ expect(LEGACY_AGENT_TOOL_MAP.generateVideo).toBe('paused:video-authorization');
+});