496 lines
27 KiB
TypeScript
496 lines
27 KiB
TypeScript
import fs from "node:fs/promises";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import { z } from "zod";
|
||
|
||
import type { CanvasSnapshot } from "../canvas/types.js";
|
||
import { logger } from "../utils/logger.js";
|
||
import { errorMessage, field, type JsonRecord } from "../utils/value.js";
|
||
import { CodexAppClient, CodexReportedError } from "./codex-client.js";
|
||
import { codexEventHistory } from "./codex-event-history.js";
|
||
import { settledTurnIds, summarizeCodexThread, threadMessages } from "./codex-history.js";
|
||
import type { CodexReasoningEffort, CodexSkillMetadata, CodexSkillSelector, CodexSkillsListEntry } from "./codex-protocol.js";
|
||
import type { AgentAttachment, AgentEmit, AgentPermissionMode } from "./types.js";
|
||
|
||
type CodexRunOptions = { threadId?: string; cwd?: string; permissionMode?: AgentPermissionMode; model?: string; effort?: CodexReasoningEffort; skill?: CodexSkillSelector; messageText?: string; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void };
|
||
type CodexSkillDraftInput = { model?: string; effort?: CodexReasoningEffort } & ({ source: "conversation"; threadId: string } | { source: "canvas"; snapshot: CanvasSnapshot });
|
||
|
||
const skillNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||
const skillDraftSchema = z.object({
|
||
name: z.string().trim().min(1).max(64).regex(skillNamePattern),
|
||
displayName: z.string().trim().max(64),
|
||
description: z.string().trim().min(1).max(1024).refine((value) => !/[<>]/.test(value)),
|
||
instructions: z.string().trim().min(1).max(20000),
|
||
shortDescription: z.string().trim().max(64).refine((value) => !value || value.length >= 25),
|
||
defaultPrompt: z.string().trim().max(1024),
|
||
}).strict().superRefine((draft, context) => {
|
||
if (draft.defaultPrompt && !mentionsSkill(draft.defaultPrompt, draft.name)) context.addIssue({ code: "custom", path: ["defaultPrompt"], message: `默认提示词必须包含 $${draft.name}` });
|
||
});
|
||
|
||
const SKILL_DRAFT_OUTPUT_SCHEMA: JsonRecord = {
|
||
type: "object",
|
||
additionalProperties: false,
|
||
required: ["name", "displayName", "description", "instructions", "shortDescription", "defaultPrompt"],
|
||
properties: {
|
||
name: { type: "string", pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$", minLength: 1, maxLength: 64 },
|
||
displayName: { type: "string", maxLength: 64 },
|
||
description: { type: "string", pattern: "^[^<>]+$", minLength: 1, maxLength: 1024 },
|
||
instructions: { type: "string", minLength: 1, maxLength: 20000 },
|
||
shortDescription: { anyOf: [{ type: "string", maxLength: 0 }, { type: "string", minLength: 25, maxLength: 64 }] },
|
||
defaultPrompt: { type: "string", maxLength: 1024 },
|
||
},
|
||
};
|
||
|
||
export type AgentSkillDraft = z.infer<typeof skillDraftSchema>;
|
||
|
||
export class CodexSkillLookupError extends Error {
|
||
override name = "CodexSkillLookupError";
|
||
constructor(message: string, readonly statusCode: 400 | 404 | 409) {
|
||
super(message);
|
||
}
|
||
}
|
||
|
||
let codexQueue: Promise<unknown> = Promise.resolve();
|
||
let codexApp: CodexAppClient | null = null;
|
||
let codexAppStart: Promise<CodexAppClient> | null = null;
|
||
/** 仅表示最近主动加载/选择的线程;运行中的 turn 身份由 CodexAppClient 自己维护。 */
|
||
let loadedThreadId = "";
|
||
|
||
export { summarizeCodexThread } from "./codex-history.js";
|
||
|
||
/** 将 Codex turn 加入串行队列并等待执行完成。 */
|
||
export async function runCodexTurn(prompt: string, lifecycleEmit: AgentEmit, attachments: AgentAttachment[] = [], options: CodexRunOptions = {}) {
|
||
if (!prompt.trim()) return;
|
||
codexQueue = codexQueue.catch(() => undefined).then(() => runCodexTurnNow(prompt, lifecycleEmit, attachments, options));
|
||
await codexQueue;
|
||
}
|
||
|
||
/** 从当前对话或指定网页画布生成可编辑草稿,不写入 Skill 文件。 */
|
||
export async function generateCodexSkillDraft(emit: AgentEmit, cwd: string, input: CodexSkillDraftInput): Promise<AgentSkillDraft> {
|
||
const queued = codexQueue.catch(() => undefined).then(() => generateCodexSkillDraftNow(emit, cwd, input));
|
||
codexQueue = queued;
|
||
return await queued;
|
||
}
|
||
|
||
/** 中断当前线程正在执行的 Codex turn。 */
|
||
export async function interruptCodexTurn(threadId?: string) {
|
||
if (!codexApp) return false;
|
||
return await codexApp.interruptCurrentTurn(threadId);
|
||
}
|
||
|
||
/** 回复当前 app-server 的待处理权限请求。 */
|
||
export async function resolveCodexApproval(requestId: string, decision: string) {
|
||
return Boolean(codexApp?.resolveApproval(requestId, decision));
|
||
}
|
||
|
||
/** 创建新的 Codex 线程并记录当前线程 ID。 */
|
||
export async function startCodexThread(emit: AgentEmit, cwd?: string, permissionMode: AgentPermissionMode = "request", preheat = false) {
|
||
const app = await getCodexApp(emit);
|
||
const thread = await app.startThread(cwd, permissionMode, preheat);
|
||
loadedThreadId = String(field(thread, "id") || "");
|
||
return thread;
|
||
}
|
||
|
||
/** 恢复指定 Codex 线程并返回聊天历史。 */
|
||
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request", preheat = false) {
|
||
const app = await getCodexApp(emit);
|
||
const thread = await resumeLoadedThread(app, threadId, cwd, permissionMode, true, preheat);
|
||
const history = await loadCodexHistory(emit, threadId, cwd);
|
||
const supplementalItems = await codexEventHistory.readThread(threadId);
|
||
return { thread, messages: threadMessages(history.thread, app.planUpdates(threadId), supplementalItems), settledTurnIds: settledTurnIds(history.thread, supplementalItems), historyReady: history.historyReady };
|
||
}
|
||
|
||
/** 查询当前工作空间中的 Codex 线程。 */
|
||
export async function listCodexThreads(emit: AgentEmit, options: { cwd: string; searchTerm?: string; limit?: number }) {
|
||
const app = await getCodexApp(emit);
|
||
const result = await app.listThreads({
|
||
limit: options.limit || 40,
|
||
sortKey: "updated_at",
|
||
sortDirection: "desc",
|
||
sourceKinds: ["cli", "vscode", "appServer", "exec"],
|
||
cwd: options.cwd,
|
||
...(options.searchTerm ? { searchTerm: options.searchTerm } : {}),
|
||
});
|
||
const data = Array.isArray(field(result, "data")) ? (field(result, "data") as unknown[]).map(summarizeCodexThread).filter((thread) => threadInWorkspace(thread, options.cwd)) : [];
|
||
return { data, nextCursor: field(result, "nextCursor") || null, backwardsCursor: field(result, "backwardsCursor") || null };
|
||
}
|
||
|
||
/** 查询当前账号可用于新任务的 Codex 模型。 */
|
||
export async function listCodexModels(emit: AgentEmit) {
|
||
return await (await getCodexApp(emit)).listModels();
|
||
}
|
||
|
||
/** 查询当前工作空间的原生 Skill 列表。 */
|
||
export async function listCodexSkills(emit: AgentEmit, cwd: string, forceReload = false): Promise<CodexSkillsListEntry> {
|
||
const result = await (await getCodexApp(emit)).listSkills(cwd, forceReload);
|
||
return result.data.find((entry) => samePath(entry.cwd, cwd)) || { cwd, skills: [], errors: [] };
|
||
}
|
||
|
||
/** 从原生 Skill 列表中解析并校验浏览器提交的选择器。 */
|
||
export async function resolveCodexSkill(emit: AgentEmit, cwd: string, selector: CodexSkillSelector, requireEnabled = false): Promise<CodexSkillMetadata> {
|
||
const name = String(selector?.name || "");
|
||
const requestedPath = String(selector?.path || "");
|
||
if (!name || !requestedPath || !path.isAbsolute(requestedPath)) throw new CodexSkillLookupError("Skill 选择无效", 400);
|
||
const { skills } = await listCodexSkills(emit, cwd, true);
|
||
const skill = skills.find((item) => item.name === name && samePath(item.path, requestedPath));
|
||
if (!skill) throw new CodexSkillLookupError("找不到指定 Skill,请刷新列表后重试", 404);
|
||
if (requireEnabled && !skill.enabled) throw new CodexSkillLookupError("该 Skill 已停用,请先启用后再使用", 409);
|
||
return skill;
|
||
}
|
||
|
||
/** 修改经过原生列表校验的 Skill 启用状态。 */
|
||
export async function configureCodexSkill(emit: AgentEmit, cwd: string, selector: CodexSkillSelector, enabled: boolean) {
|
||
const skill = await resolveCodexSkill(emit, cwd, selector);
|
||
const result = await (await getCodexApp(emit)).setSkillEnabled(skill.path, enabled);
|
||
return { ...result, skill: { ...skill, enabled: result.effectiveEnabled } };
|
||
}
|
||
|
||
/** 读取指定 Codex 线程及其聊天历史。 */
|
||
export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||
const app = await getCodexApp(emit);
|
||
const history = await loadCodexHistory(emit, threadId, cwd);
|
||
const supplementalItems = await codexEventHistory.readThread(threadId);
|
||
return { thread: summarizeCodexThread(history.thread), messages: threadMessages(history.thread, app.planUpdates(threadId), supplementalItems), settledTurnIds: settledTurnIds(history.thread, supplementalItems), historyReady: history.historyReady };
|
||
}
|
||
|
||
/** 归档指定 Codex 线程。 */
|
||
export async function archiveCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||
const app = await getCodexApp(emit);
|
||
try {
|
||
await loadCodexThread(emit, threadId, cwd, false);
|
||
} catch (error) {
|
||
if (!isRecoverableThreadError(error)) throw error;
|
||
await resumeLoadedThread(app, threadId, cwd, "request", false);
|
||
}
|
||
await app.archiveThread(threadId);
|
||
app.clearPlanUpdates(threadId);
|
||
await codexEventHistory.removeThread(threadId);
|
||
if (loadedThreadId === threadId) loadedThreadId = "";
|
||
}
|
||
|
||
/** 判断线程异常是否允许自动新建线程后重试。 */
|
||
export function isRecoverableThreadError(error: unknown) {
|
||
return /thread not loaded|no rollout found/i.test(errorMessage(error));
|
||
}
|
||
|
||
/** 执行一次 Codex turn,并负责附件临时文件和线程恢复。 */
|
||
async function runCodexTurnNow(prompt: string, lifecycleEmit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
|
||
let files: string[] = [];
|
||
try {
|
||
options.onStart?.();
|
||
files = await writeAttachmentFiles(attachments);
|
||
const app = await getCodexApp(options.appEmit || lifecycleEmit);
|
||
let threadId = await ensureCodexThread(app, options, lifecycleEmit);
|
||
options.onThread?.(threadId);
|
||
try {
|
||
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn, options.skill, options.messageText);
|
||
} catch (error) {
|
||
if (!isRecoverableThreadError(error)) throw error;
|
||
lifecycleEmit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
|
||
loadedThreadId = "";
|
||
threadId = await ensureCodexThread(app, { cwd: options.cwd }, lifecycleEmit);
|
||
options.onThread?.(threadId);
|
||
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn, options.skill, options.messageText);
|
||
}
|
||
} catch (error) {
|
||
logger.error("Codex turn failed", error);
|
||
if (!(error instanceof CodexReportedError)) lifecycleEmit("agent_error", { message: errorMessage(error) });
|
||
} finally {
|
||
options.onFinish?.();
|
||
await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
|
||
}
|
||
}
|
||
|
||
/** 恢复请求线程或创建新的 Codex 线程。 */
|
||
async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions, emit: AgentEmit) {
|
||
if (options.threadId) {
|
||
if (options.threadId === loadedThreadId) return loadedThreadId;
|
||
try {
|
||
await resumeLoadedThread(app, options.threadId, options.cwd, options.permissionMode || "request", true);
|
||
return loadedThreadId;
|
||
} catch (error) {
|
||
if (!isRecoverableThreadError(error)) throw error;
|
||
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
|
||
loadedThreadId = "";
|
||
}
|
||
}
|
||
if (!loadedThreadId) {
|
||
const thread = await app.startThread(options.cwd, options.permissionMode || "request");
|
||
loadedThreadId = String(field(thread, "id") || "");
|
||
}
|
||
return loadedThreadId;
|
||
}
|
||
|
||
/** 从 app-server 读取线程并校验工作空间。 */
|
||
async function loadCodexThread(emit: AgentEmit, threadId: string, cwd: string | undefined, includeTurns: boolean) {
|
||
const app = await getCodexApp(emit);
|
||
const result = await app.readThread(threadId, includeTurns);
|
||
const thread = field(result, "thread") || {};
|
||
assertThreadWorkspace(thread, cwd);
|
||
return thread;
|
||
}
|
||
|
||
async function generateCodexSkillDraftNow(emit: AgentEmit, cwd: string, input: CodexSkillDraftInput) {
|
||
const app = await getCodexApp(emit);
|
||
let threadId = "";
|
||
try {
|
||
const thread = input.source === "conversation" ? await app.forkSkillDraftThread(input.threadId, cwd) : await app.startSkillDraftThread(cwd);
|
||
threadId = String(field(thread, "id") || "");
|
||
const raw = await app.generateSkillDraft(threadId, skillDraftPrompt(input), SKILL_DRAFT_OUTPUT_SCHEMA, input.model, input.effort);
|
||
let value: unknown;
|
||
try {
|
||
value = JSON.parse(raw);
|
||
} catch {
|
||
throw new Error("Codex 返回的 Skill 草稿不是有效 JSON");
|
||
}
|
||
const parsed = skillDraftSchema.safeParse(value);
|
||
if (!parsed.success) throw new Error("Codex 返回的 Skill 草稿格式不正确");
|
||
assertDraftHasNoSensitiveValues(parsed.data, input.source === "canvas" ? canvasPrivateValues(input.snapshot) : []);
|
||
return parsed.data;
|
||
} finally {
|
||
if (threadId) await app.closeSkillDraftThread(threadId).catch((error) => logger.warn("Failed to release Skill draft thread", { threadId, error }));
|
||
}
|
||
}
|
||
|
||
function skillDraftPrompt(input: CodexSkillDraftInput) {
|
||
const source = input.source === "conversation"
|
||
? "从这个临时分支继承的完整对话中,识别已经实际完成且值得复用的稳定流程。不要总结本条提炼请求,也不要保留一次性的结论、错误排查过程或工具日志。"
|
||
: `从下面经过清理的画布快照中,识别节点、连线和生成步骤所表达的可复用流程。不要把画布节点 ID 写进执行说明。\n\n画布快照:\n${JSON.stringify(canvasSkillSource(input.snapshot))}`;
|
||
return [
|
||
"请生成一个可编辑的 Codex Skill 草稿。",
|
||
source,
|
||
"要求:",
|
||
"- name 使用不超过 64 个字符的小写字母、数字和连字符,优先使用简短的动词短语。",
|
||
"- description 同时说明能力和触发场景;所有何时使用的信息都写在这里。",
|
||
"- instructions 只写另一个 Codex 真正需要的、可复用的命令式步骤、约束和输出要求,不写 YAML frontmatter。",
|
||
"- shortDescription 写 25–64 个字符的人类可读短说明;没有合适内容时返回空字符串。",
|
||
"- defaultPrompt 必须包含与 name 完全一致的 $skill-name 调用标记;没有合适内容时返回空字符串。",
|
||
"- displayName 使用简洁的人类可读名称。",
|
||
"- 不得输出 Token、API Key、密码、凭证、本地路径、媒体 URL、敏感 URL、临时错误、调试日志或一次性结果。",
|
||
"只按 outputSchema 返回对象。",
|
||
].join("\n");
|
||
}
|
||
|
||
const MAX_CANVAS_SKILL_NODES = 300;
|
||
const MAX_CANVAS_SKILL_CONNECTIONS = 600;
|
||
const MAX_CANVAS_SKILL_NODE_CHARS = 100_000;
|
||
const MAX_CANVAS_SKILL_SOURCE_CHARS = 120_000;
|
||
|
||
/** 只保留理解画布流程所需的信息,避免把媒体、外部地址、坐标和本地凭证送入草稿线程。 */
|
||
export function canvasSkillSource(snapshot: CanvasSnapshot): JsonRecord {
|
||
const allNodes = prioritizedCanvasNodes(snapshot);
|
||
const nodes = allNodes.slice(0, MAX_CANVAS_SKILL_NODES);
|
||
const nodeRefs = new Map(allNodes.map((node, index) => [node.id, `node-${index + 1}`]));
|
||
const title = cleanCanvasString(snapshot.title, nodeRefs);
|
||
const source: JsonRecord & { nodes: JsonRecord[]; connections: Array<{ from: string; to: string }> } = {
|
||
...(title ? { title } : {}),
|
||
nodes: [],
|
||
connections: [],
|
||
};
|
||
let truncated = nodes.length < allNodes.length;
|
||
nodes.forEach((node, index) => {
|
||
const metadata = { ...(node.metadata || {}) };
|
||
if (node.type !== "text") delete metadata.content;
|
||
const cleanMetadata = sanitizeCanvasValue(metadata, nodeRefs);
|
||
const nodeTitle = cleanCanvasString(node.title, nodeRefs);
|
||
const summary = { ref: `node-${index + 1}`, type: node.type, ...(nodeTitle ? { title: nodeTitle } : {}) };
|
||
const candidate = { ...summary, ...(cleanMetadata && Object.keys(cleanMetadata as JsonRecord).length ? { metadata: cleanMetadata } : {}) };
|
||
if (canvasSourceFits({ ...source, nodes: [...source.nodes, candidate] }, MAX_CANVAS_SKILL_NODE_CHARS)) source.nodes.push(candidate);
|
||
else if (canvasSourceFits({ ...source, nodes: [...source.nodes, summary] }, MAX_CANVAS_SKILL_NODE_CHARS)) (source.nodes.push(summary), truncated = true);
|
||
else truncated = true;
|
||
});
|
||
const includedRefs = new Set(source.nodes.map((node) => String(node.ref || "")));
|
||
const selectedNodeRefs = (snapshot.selectedNodeIds || []).flatMap((id) => nodeRefs.get(id) || []).filter((ref) => includedRefs.has(ref));
|
||
if (selectedNodeRefs.length) source.selectedNodeRefs = selectedNodeRefs;
|
||
const connections = (snapshot.connections || []).flatMap(({ fromNodeId, toNodeId }) => {
|
||
const from = nodeRefs.get(fromNodeId);
|
||
const to = nodeRefs.get(toNodeId);
|
||
return from && to && includedRefs.has(from) && includedRefs.has(to) ? [{ from, to }] : [];
|
||
});
|
||
if (connections.length > MAX_CANVAS_SKILL_CONNECTIONS) truncated = true;
|
||
connections.slice(0, MAX_CANVAS_SKILL_CONNECTIONS).forEach((connection) => {
|
||
if (canvasSourceFits({ ...source, connections: [...source.connections, connection] }, MAX_CANVAS_SKILL_SOURCE_CHARS - 32)) source.connections.push(connection);
|
||
else truncated = true;
|
||
});
|
||
if (truncated) source.truncated = true;
|
||
return source;
|
||
}
|
||
|
||
const sensitiveCanvasKey = /api.?key|token|secret|password|authorization|credential|storage.?key|(?:local|file).?path/i;
|
||
const transientCanvasKey = /^(?:status|progress|errorDetails|taskId|createdAt|updatedAt|startedAt|completedAt)$/i;
|
||
const canvasNodeReferenceKey = /^.*(?:Node|Group|Parent|Child|Root|Source|Target|PrimaryImage)Ids?$/i;
|
||
const directLocalPath = /^(?:file:(?:\/\/)?|[a-z]:[\\/]|\\\\|\/(?!\/)(?=[^\s`'"“”<>]+\/))/i;
|
||
const fileUrl = /\bfile:(?:\/\/)?[^\s`'"“”<>]+/gi;
|
||
const inlineLocalPath = /(?<![A-Za-z0-9/:])(?:[a-z]:[\\/]|\\\\)[^\s`'"“”<>]+|(?<![\p{L}\p{N}/:])\/(?!\/)(?=[^\s`'"“”<>]+\/)[^\s`'"“”<>]+/giu;
|
||
const credentialAssignment = /(?:api[_ -]?key|access[_ -]?(?:key|token)|connect[_ -]?token|token|secret|password|authorization|credential)\s*(?:[:=:]|为|是)\s*(?:bearer\s+)?[`'"“]?[A-Za-z0-9_./+\-=]{8,}/gi;
|
||
const bearerToken = /\bbearer\s+[A-Za-z0-9._~+/=\-]{8,}/gi;
|
||
const jwtToken = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g;
|
||
const knownApiToken = /\b(?:sk-[A-Za-z0-9_-]{12,}|(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{20,}|AKIA[A-Z0-9]{16})\b/g;
|
||
const transientIdentifier = /\b(?:task|job|request|generation|node)[_-](?:\d{4,}|[A-Fa-f0-9]{8,}|(?=[A-Za-z0-9_-]{12,}\b)(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]+)\b/gi;
|
||
const webUrl = /\bhttps?:\/\/[^\s<>{}\[\]`'"“”]+/gi;
|
||
|
||
function sanitizeCanvasValue(value: unknown, nodeRefs: Map<string, string>, depth = 0): unknown {
|
||
if (value === null || typeof value === "boolean" || typeof value === "number") return value;
|
||
if (typeof value === "string") return cleanCanvasString(value, nodeRefs);
|
||
if (depth >= 6) return undefined;
|
||
if (Array.isArray(value)) return value.slice(0, 300).map((item) => sanitizeCanvasValue(item, nodeRefs, depth + 1)).filter((item) => item !== undefined);
|
||
if (!value || typeof value !== "object") return undefined;
|
||
const result: JsonRecord = {};
|
||
Object.entries(value as JsonRecord).forEach(([key, item]) => {
|
||
if (sensitiveCanvasKey.test(key) || transientCanvasKey.test(key)) return;
|
||
if (canvasNodeReferenceKey.test(key)) {
|
||
const refs = (Array.isArray(item) ? item : [item]).flatMap((id) => typeof id === "string" ? nodeRefs.get(id) || [] : []);
|
||
if (refs.length) result[key.replace(/Ids$/i, "Refs").replace(/Id$/i, "Ref")] = Array.isArray(item) ? [...new Set(refs)] : refs[0];
|
||
return;
|
||
}
|
||
const clean = sanitizeCanvasValue(item, nodeRefs, depth + 1);
|
||
if (clean !== undefined) result[replaceCanvasNodeRefs(key, nodeRefs)] = clean;
|
||
});
|
||
return result;
|
||
}
|
||
|
||
function cleanCanvasString(value: unknown, nodeRefs: Map<string, string>) {
|
||
const valueText = typeof value === "string" ? value.trim() : "";
|
||
if (!valueText || /^(?:data:|blob:|https?:\/\/|file:)/i.test(valueText) || directLocalPath.test(valueText)) return undefined;
|
||
const text = replaceCanvasNodeRefs(valueText, nodeRefs)
|
||
.replace(/\b(?:data:|blob:)[^\s`'"“”<>]+/gi, "[媒体地址已移除]")
|
||
.replace(fileUrl, "[本地路径已移除]")
|
||
.replace(webUrl, "[外部地址已移除]")
|
||
.replace(inlineLocalPath, "[本地路径已移除]")
|
||
.replace(credentialAssignment, "[敏感凭证已移除]")
|
||
.replace(bearerToken, "[敏感凭证已移除]")
|
||
.replace(jwtToken, "[敏感凭证已移除]")
|
||
.replace(knownApiToken, "[敏感凭证已移除]")
|
||
.trim();
|
||
return text.length > 12000 ? `${text.slice(0, 12000)}\n[内容已截断]` : text || undefined;
|
||
}
|
||
|
||
function replaceCanvasNodeRefs(value: string, nodeRefs: Map<string, string>) {
|
||
const nodeIds = [...nodeRefs.keys()].filter(Boolean).sort((left, right) => right.length - left.length);
|
||
const nodeIdPattern = nodeIds.length ? new RegExp(nodeIds.map((id) => id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), "g") : undefined;
|
||
return nodeIdPattern ? value.replace(nodeIdPattern, (id) => nodeRefs.get(id) || id) : value;
|
||
}
|
||
|
||
function prioritizedCanvasNodes(snapshot: CanvasSnapshot) {
|
||
const nodes = snapshot.nodes || [];
|
||
const selectedIds = new Set(snapshot.selectedNodeIds || []);
|
||
const relatedIds = new Set<string>();
|
||
(snapshot.connections || []).forEach(({ fromNodeId, toNodeId }) => {
|
||
if (selectedIds.has(fromNodeId)) relatedIds.add(toNodeId);
|
||
if (selectedIds.has(toNodeId)) relatedIds.add(fromNodeId);
|
||
});
|
||
return [
|
||
...nodes.filter((node) => selectedIds.has(node.id)),
|
||
...nodes.filter((node) => !selectedIds.has(node.id) && relatedIds.has(node.id)),
|
||
...nodes.filter((node) => !selectedIds.has(node.id) && !relatedIds.has(node.id)),
|
||
];
|
||
}
|
||
|
||
function canvasSourceFits(source: JsonRecord, limit: number) {
|
||
return JSON.stringify(source).length <= limit;
|
||
}
|
||
|
||
function canvasPrivateValues(snapshot: CanvasSnapshot) {
|
||
return [snapshot.projectId, snapshot.clientId, ...(snapshot.nodes || []).map((node) => node.id), ...(snapshot.connections || []).map((connection) => connection.id)]
|
||
.filter((value): value is string => typeof value === "string" && value.length >= 6);
|
||
}
|
||
|
||
function patternMatches(pattern: RegExp, text: string) {
|
||
pattern.lastIndex = 0;
|
||
return pattern.test(text);
|
||
}
|
||
|
||
function mentionsSkill(prompt: string, name: string) {
|
||
return new RegExp(`\\$${name}(?![A-Za-z0-9_-]|:[A-Za-z0-9_-])`).test(prompt);
|
||
}
|
||
|
||
export function assertDraftHasNoSensitiveValues(draft: AgentSkillDraft, privateValues: string[]) {
|
||
const text = Object.values(draft).join("\n");
|
||
const localPath = /(?:^|[\s`'"“”((\[{,:;:])(?:file:(?:\/\/)?|[a-z]:[\\/]|\\\\|\/(?!\/))/im;
|
||
const externalUrl = (text.match(webUrl) || []).length > 0;
|
||
const hasPrivateValue = privateValues.some((value) => text.includes(value));
|
||
if (localPath.test(text) || /\b(?:data:|blob:)/i.test(text) || patternMatches(credentialAssignment, text) || patternMatches(bearerToken, text) || patternMatches(jwtToken, text) || patternMatches(knownApiToken, text) || patternMatches(transientIdentifier, text) || externalUrl || hasPrivateValue) {
|
||
throw new Error("生成的 Skill 草稿包含外部地址、本地路径、敏感凭证或一次性标识,已拒绝返回");
|
||
}
|
||
}
|
||
|
||
/** 读取线程历史,并显式标记 Codex 是否已经物化 turns。 */
|
||
async function loadCodexHistory(emit: AgentEmit, threadId: string, cwd?: string) {
|
||
try {
|
||
return { thread: await loadCodexThread(emit, threadId, cwd, true), historyReady: true };
|
||
} catch (error) {
|
||
if (/not materialized yet.*includeTurns/i.test(errorMessage(error))) return { thread: await loadCodexThread(emit, threadId, cwd, false), historyReady: false };
|
||
if (!isRecoverableThreadError(error)) throw error;
|
||
const app = await getCodexApp(emit);
|
||
const thread = await resumeLoadedThread(app, threadId, cwd, "request", false);
|
||
try {
|
||
return { thread: await loadCodexThread(emit, threadId, cwd, true), historyReady: true };
|
||
} catch (historyError) {
|
||
if (/not materialized yet.*includeTurns/i.test(errorMessage(historyError))) return { thread, historyReady: false };
|
||
throw historyError;
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 恢复线程并统一校验工作空间与进程内活动线程。 */
|
||
async function resumeLoadedThread(app: CodexAppClient, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request", updateLoaded = true, preheat = false) {
|
||
const thread = await app.resumeThread(threadId, cwd, permissionMode, preheat);
|
||
assertThreadWorkspace(thread, cwd);
|
||
if (updateLoaded) loadedThreadId = String(field(thread, "id") || threadId);
|
||
return thread;
|
||
}
|
||
|
||
/** 获取已启动的 Codex app-server 客户端。 */
|
||
async function getCodexApp(emit: AgentEmit) {
|
||
if (codexApp) return codexApp;
|
||
codexAppStart ||= CodexAppClient.start(emit, () => {
|
||
codexApp = null;
|
||
loadedThreadId = "";
|
||
});
|
||
try {
|
||
codexApp = await codexAppStart;
|
||
return codexApp;
|
||
} finally {
|
||
codexAppStart = null;
|
||
}
|
||
}
|
||
|
||
/** 校验线程是否属于指定工作空间。 */
|
||
function assertThreadWorkspace(thread: unknown, cwd?: string) {
|
||
if (!cwd || threadInWorkspace(thread, cwd)) return;
|
||
throw new Error("该 Codex 会话不属于当前画布工作空间");
|
||
}
|
||
|
||
/** 判断线程工作目录是否与当前工作空间一致。 */
|
||
function threadInWorkspace(thread: unknown, cwd: string) {
|
||
const threadCwd = String(field(thread, "cwd") || "");
|
||
return Boolean(threadCwd && samePath(threadCwd, cwd));
|
||
}
|
||
|
||
/** 比较跨平台绝对路径;Windows 路径不区分大小写。 */
|
||
function samePath(left: string, right: string) {
|
||
const normalize = (value: string) => process.platform === "win32" ? path.resolve(value).toLowerCase() : path.resolve(value);
|
||
return normalize(left) === normalize(right);
|
||
}
|
||
|
||
/** 将图片附件写入临时文件供 Codex 读取。 */
|
||
async function writeAttachmentFiles(attachments: AgentAttachment[]) {
|
||
return await Promise.all(attachments.filter((item) => item.dataUrl?.startsWith("data:image/")).map(writeAttachmentFile));
|
||
}
|
||
|
||
/** 将单个 Data URL 图片附件写入临时文件。 */
|
||
async function writeAttachmentFile(item: AgentAttachment) {
|
||
const [, meta = "", data = ""] = item.dataUrl?.match(/^data:([^;]+);base64,(.+)$/) || [];
|
||
if (!data) throw new Error(`图片附件无效:${item.name || "未命名图片"}`);
|
||
const file = path.join(os.tmpdir(), `infinite-canvas-${Date.now()}-${Math.random().toString(16).slice(2)}.${imageExt(meta || item.type)}`);
|
||
await fs.writeFile(file, Buffer.from(data, "base64"));
|
||
return file;
|
||
}
|
||
|
||
/** 根据图片 MIME 类型返回临时文件扩展名。 */
|
||
function imageExt(type = "") {
|
||
if (type.includes("png")) return "png";
|
||
if (type.includes("webp")) return "webp";
|
||
return "jpg";
|
||
}
|