ship 1.4.64:Arcrun@1eb26c98a3af
This commit is contained in:
@@ -29,7 +29,7 @@ Served via jsDelivr; fetched automatically during install — you never need to
|
||||
- `arcrun-wait/` — **arcrun-wait**(首裝)
|
||||
- `daemon/` — 桌面 App(Mac/Windows)安裝檔
|
||||
|
||||
Built from `Arcrun@e92d6e683271` by `installer/scripts/ship.mjs`(arcrun-rag repo,release 1.4.63,built 2026-08-29)。
|
||||
Built from `Arcrun@1eb26c98a3af` by `installer/scripts/ship.mjs`(arcrun-rag repo,release 1.4.64,built 2026-09-13)。
|
||||
|
||||
⚠️ 這份檔案由出貨管線每次自動重寫(`installer/scripts/render-bundles-readme.mjs`)——
|
||||
不要手動改這裡列的零件清單——它是算出來的:公庫=Arcrun 這一版編了什麼,
|
||||
|
||||
@@ -3525,11 +3525,11 @@ function invalidateCredentialCache(apiKey) {
|
||||
async function getCredentialDirectory(env, apiKey) {
|
||||
const now2 = Date.now();
|
||||
const cached = dirCache[apiKey];
|
||||
if (cached && now2 - cached.fetchedAt < DIR_CACHE_TTL_MS) return cached.rows;
|
||||
if (cached && now2 - cached.fetchedAt < DIR_CACHE_TTL_MS) return { rows: cached.rows, error: null };
|
||||
const qs = new URLSearchParams({ owner_id: apiKey, entry_type: CREDENTIAL_ENTRY_TYPE, limit: "200" });
|
||||
const res = await kbdbCredFetch(env, `/entries?${qs.toString()}`);
|
||||
if (!res.ok) {
|
||||
return [];
|
||||
return { rows: [], error: `KBDB \u56DE HTTP ${res.status}` };
|
||||
}
|
||||
const body = await res.json().catch(() => null);
|
||||
const rows = (body?.entries ?? []).filter((e) => !!e.page_name).map((e) => {
|
||||
@@ -3544,15 +3544,15 @@ async function getCredentialDirectory(env, apiKey) {
|
||||
};
|
||||
});
|
||||
dirCache[apiKey] = { rows, fetchedAt: now2 };
|
||||
return rows;
|
||||
return { rows, error: null };
|
||||
}
|
||||
async function getCredentialSecretRefs(env, apiKey) {
|
||||
const rows = await getCredentialDirectory(env, apiKey);
|
||||
const out = {};
|
||||
async function getCredentialSecretRefsDetailed(env, apiKey) {
|
||||
const { rows, error } = await getCredentialDirectory(env, apiKey);
|
||||
const refs = {};
|
||||
for (const r of rows) {
|
||||
if (r.secret_ref) out[r.name] = r.secret_ref;
|
||||
if (r.secret_ref) refs[r.name] = r.secret_ref;
|
||||
}
|
||||
return out;
|
||||
return { refs, directoryError: error };
|
||||
}
|
||||
function touchLastUsed(env, apiKey, names) {
|
||||
const cached = dirCache[apiKey];
|
||||
@@ -3560,6 +3560,7 @@ function touchLastUsed(env, apiKey, names) {
|
||||
const now2 = Math.floor(Date.now() / 1e3);
|
||||
for (const r of cached.rows) {
|
||||
if (!names.includes(r.name)) continue;
|
||||
if (typeof r.last_used_at === "number" && now2 - r.last_used_at < LAST_USED_MIN_INTERVAL_S) continue;
|
||||
const meta = {
|
||||
service: r.service,
|
||||
sensitivity: r.sensitivity,
|
||||
@@ -3639,7 +3640,7 @@ async function writeCredential(env, apiKey, name, value, service, sensitivityRaw
|
||||
await upsertCredentialEntry(env, apiKey, name, service ?? null, sensitivity, secretRef);
|
||||
return { secretRef, sensitivity };
|
||||
}
|
||||
var credentialsRouter, CYPHER_SCRIPT_NAME, CREDENTIAL_ENTRY_TYPE, DIR_CACHE_TTL_MS, dirCache;
|
||||
var credentialsRouter, CYPHER_SCRIPT_NAME, CREDENTIAL_ENTRY_TYPE, DIR_CACHE_TTL_MS, dirCache, LAST_USED_MIN_INTERVAL_S, VALUE_LIKE_FIELDS;
|
||||
var init_credentials = __esm({
|
||||
"cypher-executor/src/routes/credentials.ts"() {
|
||||
"use strict";
|
||||
@@ -3651,6 +3652,43 @@ var init_credentials = __esm({
|
||||
CREDENTIAL_ENTRY_TYPE = "credential";
|
||||
DIR_CACHE_TTL_MS = 6e4;
|
||||
dirCache = {};
|
||||
LAST_USED_MIN_INTERVAL_S = 300;
|
||||
VALUE_LIKE_FIELDS = ["value", "secret", "token", "text", "plaintext"];
|
||||
credentialsRouter.post("/credentials/directory", async (c) => {
|
||||
const apiKey = c.req.header("X-Arcrun-API-Key");
|
||||
if (!apiKey) {
|
||||
return c.json({ error: "\u7F3A\u5C11 X-Arcrun-API-Key header" }, 401);
|
||||
}
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const name = body?.name;
|
||||
if (!validateName(name)) {
|
||||
return c.json({ error: "name \u5FC5\u586B\uFF0C\u53EA\u80FD\u5305\u542B\u82F1\u6587\u5B57\u6BCD\u3001\u6578\u5B57\u548C\u5E95\u7DDA" }, 400);
|
||||
}
|
||||
const offending = VALUE_LIKE_FIELDS.filter((f) => body?.[f] !== void 0);
|
||||
if (offending.length > 0) {
|
||||
return c.json({
|
||||
error: `\u9019\u652F\u7AEF\u9EDE\u53EA\u5BEB\u76EE\u9304\uFF0C\u4E0D\u6536\u91D1\u9470\u503C\uFF08\u6536\u5230 ${offending.join("/")}\uFF09\u3002\u503C\u8ACB\u7531\u6301\u6709 Cloudflare \u5BEB\u5165\u6191\u8B49\u7684\u4E00\u65B9\u76F4\u63A5 PUT \u9032 Workers Secrets\uFF0Csecret \u540D\u7A31\u7528\u672C\u7AEF\u9EDE\u56DE\u7684 secret_ref\uFF08D36\uFF1A\u53EA\u6709\u4E00\u689D\u91D1\u9470\u50B3\u905E\u8DEF\u5F91\uFF09\u3002`
|
||||
}, 400);
|
||||
}
|
||||
const service = typeof body?.service === "string" ? body.service : null;
|
||||
const sensitivity = validSensitivity(body?.sensitivity) ? body.sensitivity : "standard";
|
||||
try {
|
||||
const secretRef = await deriveSecretRef(apiKey, name);
|
||||
await upsertCredentialEntry(c.env, apiKey, name, service, sensitivity, secretRef);
|
||||
return c.json({
|
||||
success: true,
|
||||
name,
|
||||
service,
|
||||
sensitivity,
|
||||
// 呼叫端拿這兩個值去寫值那一半:PUT /accounts/:id/workers/scripts/{secret_script}/secrets
|
||||
// body { name: secret_ref, text: <明文>, type: 'secret_text' }。
|
||||
secret_ref: secretRef,
|
||||
secret_script: CYPHER_SCRIPT_NAME
|
||||
});
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
|
||||
}
|
||||
});
|
||||
credentialsRouter.post("/credentials", async (c) => {
|
||||
const apiKey = c.req.header("X-Arcrun-API-Key");
|
||||
if (!apiKey) {
|
||||
@@ -3754,13 +3792,13 @@ var init_credentials = __esm({
|
||||
});
|
||||
|
||||
// cypher-executor/src/actions/auth-dispatcher.ts
|
||||
async function resolveSecretsFromNewHome(env, apiKey, names) {
|
||||
async function resolveSecretsFromNewHomeDetailed(env, apiKey, names) {
|
||||
const resolved = {};
|
||||
if (names.length === 0) return resolved;
|
||||
const refs = await getCredentialSecretRefs(env, apiKey);
|
||||
if (Object.keys(refs).length === 0) return resolved;
|
||||
if (names.length === 0) return { resolved, directoryError: null };
|
||||
const { refs, directoryError } = await getCredentialSecretRefsDetailed(env, apiKey);
|
||||
if (Object.keys(refs).length === 0) return { resolved, directoryError };
|
||||
const secretGet2 = createArcrunHostFunctions(env, apiKey).secret_get;
|
||||
if (!secretGet2) return resolved;
|
||||
if (!secretGet2) return { resolved, directoryError };
|
||||
const resolvedNames = [];
|
||||
for (const name of names) {
|
||||
const ref = refs[name];
|
||||
@@ -3771,9 +3809,13 @@ async function resolveSecretsFromNewHome(env, apiKey, names) {
|
||||
resolvedNames.push(name);
|
||||
}
|
||||
if (resolvedNames.length > 0) touchLastUsed(env, apiKey, resolvedNames);
|
||||
return resolved;
|
||||
return { resolved, directoryError };
|
||||
}
|
||||
async function tryAuthDispatch(componentId, input, env, apiKey) {
|
||||
function explainCredentialFailure(message, directoryError, names) {
|
||||
if (!directoryError) return message;
|
||||
return `credential \u76EE\u9304\u8B80\u4E0D\u5230\uFF08${directoryError}\uFF09\uFF0C${names.join("\u3001")} \u7121\u6CD5\u5F9E\u4FDD\u7BA1\u8655\u53D6\u7528\u2014\u2014\u9019\u4E0D\u4EE3\u8868 credential \u4E0D\u5B58\u5728\uFF0C\u662F\u77E5\u8B58\u5EAB\uFF08KBDB\uFF09\u9019\u4E00\u523B\u56DE\u932F\uFF0C\u8ACB\u5148\u78BA\u8A8D KBDB \u662F\u5426\u6B63\u5E38\u3002\u9000\u56DE\u820A\u8DEF\u5F91\u7684\u7D50\u679C\uFF1A${message}`;
|
||||
}
|
||||
async function tryAuthDispatch(componentId, input, env, apiKey, redactor) {
|
||||
if (AUTH_PRIMITIVE_IDS.has(componentId)) {
|
||||
return null;
|
||||
}
|
||||
@@ -3786,7 +3828,8 @@ async function tryAuthDispatch(componentId, input, env, apiKey) {
|
||||
if (!recipe) return null;
|
||||
if (!SUPPORTED_PRIMITIVES.has(recipe.primitive)) return null;
|
||||
const secretNames = recipe.required_secrets.filter((s) => !s.optional).map((s) => s.key);
|
||||
const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, secretNames);
|
||||
const { resolved: resolvedSecrets, directoryError } = await resolveSecretsFromNewHomeDetailed(env, apiKey, secretNames);
|
||||
redactor?.addRecord(resolvedSecrets, (name) => `credential:${name}`);
|
||||
const primitiveUrl = wasmWorkerUrl(`auth_${recipe.primitive}`, env.WORKER_SUBDOMAIN);
|
||||
const res = await fetch(primitiveUrl, {
|
||||
method: "POST",
|
||||
@@ -3802,15 +3845,23 @@ async function tryAuthDispatch(componentId, input, env, apiKey) {
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`auth primitive "${recipe.primitive}" \u56DE\u50B3 ${res.status}: ${text.slice(0, 200)}`
|
||||
explainCredentialFailure(
|
||||
`auth primitive "${recipe.primitive}" \u56DE\u50B3 ${res.status}: ${text.slice(0, 200)}`,
|
||||
directoryError,
|
||||
secretNames
|
||||
)
|
||||
);
|
||||
}
|
||||
const result = await res.json().catch(() => null);
|
||||
if (!result || result.success === false) {
|
||||
throw new Error(
|
||||
`auth primitive \u5931\u6557: ${result?.error ?? "\u672A\u77E5\u932F\u8AA4"}`
|
||||
explainCredentialFailure(`auth primitive \u5931\u6557: ${result?.error ?? "\u672A\u77E5\u932F\u8AA4"}`, directoryError, secretNames)
|
||||
);
|
||||
}
|
||||
redactor?.addRecord(result.auth_headers, (k) => `auth_header:${k}`);
|
||||
redactor?.addRecord(result.auth_query, (k) => `auth_query:${k}`);
|
||||
redactor?.addRecord(result.auth_body, (k) => `auth_body:${k}`);
|
||||
redactor?.addRecord(result.auth_path, (k) => `auth_path:${k}`);
|
||||
return {
|
||||
...input,
|
||||
_auth_headers: result.auth_headers ?? {},
|
||||
@@ -3845,12 +3896,13 @@ function replaceCredentialRefs(value, resolved) {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
async function resolveCredentialRefs(data, env, apiKey) {
|
||||
async function resolveCredentialRefs(data, env, apiKey, redactor) {
|
||||
const names = /* @__PURE__ */ new Set();
|
||||
collectCredentialNames(data, names);
|
||||
if (names.size === 0) return data;
|
||||
const nameList = [...names];
|
||||
const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, nameList);
|
||||
const { resolved: resolvedSecrets, directoryError } = await resolveSecretsFromNewHomeDetailed(env, apiKey, nameList);
|
||||
redactor?.addRecord(resolvedSecrets, (name) => `credential:${name}`);
|
||||
if (nameList.every((n) => Object.prototype.hasOwnProperty.call(resolvedSecrets, n))) {
|
||||
return replaceCredentialRefs(data, resolvedSecrets);
|
||||
}
|
||||
@@ -3867,12 +3919,17 @@ async function resolveCredentialRefs(data, env, apiKey) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`credential resolve \u56DE\u50B3 ${res.status}: ${text.slice(0, 200)}`);
|
||||
throw new Error(
|
||||
explainCredentialFailure(`credential resolve \u56DE\u50B3 ${res.status}: ${text.slice(0, 200)}`, directoryError, nameList)
|
||||
);
|
||||
}
|
||||
const result = await res.json().catch(() => null);
|
||||
if (!result || result.success === false) {
|
||||
throw new Error(`credential resolve \u5931\u6557: ${result?.error ?? "\u672A\u77E5\u932F\u8AA4"}`);
|
||||
throw new Error(
|
||||
explainCredentialFailure(`credential resolve \u5931\u6557: ${result?.error ?? "\u672A\u77E5\u932F\u8AA4"}`, directoryError, nameList)
|
||||
);
|
||||
}
|
||||
redactor?.addRecord(result.credentials, (name) => `credential:${name}`);
|
||||
return replaceCredentialRefs(data, result.credentials ?? {});
|
||||
}
|
||||
var SUPPORTED_PRIMITIVES, AUTH_PRIMITIVE_IDS, CREDENTIAL_REF;
|
||||
@@ -8204,6 +8261,17 @@ var init_magic_vars = __esm({
|
||||
});
|
||||
|
||||
// cypher-executor/src/lib/telemetry.ts
|
||||
function recordNodeSteps(env, apiKey, workflowName, steps, ctx) {
|
||||
if (steps.length === 0) return;
|
||||
const failed = steps.filter((s) => !s.ok).length;
|
||||
recordTelemetry(env, apiKey, {
|
||||
event_type: "node_steps",
|
||||
workflow_name: workflowName,
|
||||
duration_ms: steps.reduce((sum, s) => sum + s.duration_ms, 0),
|
||||
...failed > 0 ? { error_code: "node_error" } : {},
|
||||
steps
|
||||
}, ctx);
|
||||
}
|
||||
async function hashApiKey(apiKey) {
|
||||
if (!apiKey) return "anon";
|
||||
const encoder = new TextEncoder();
|
||||
@@ -8263,6 +8331,93 @@ var init_telemetry = __esm({
|
||||
}
|
||||
});
|
||||
|
||||
// cypher-executor/src/lib/trace-redaction.ts
|
||||
function redactionMarker(label) {
|
||||
return `[redacted:${label}]`;
|
||||
}
|
||||
var MIN_SUBSTRING_LEN, MAX_DEPTH, TraceRedactor;
|
||||
var init_trace_redaction = __esm({
|
||||
"cypher-executor/src/lib/trace-redaction.ts"() {
|
||||
"use strict";
|
||||
MIN_SUBSTRING_LEN = 4;
|
||||
MAX_DEPTH = 64;
|
||||
TraceRedactor = class {
|
||||
/** 真身 → 標記用的 label。用 Map 讓同一個值只登記一次。 */
|
||||
labels = /* @__PURE__ */ new Map();
|
||||
/** 依長度由長到短排序的真身清單(先換長的,避免長值被短值切碎)。 */
|
||||
sortedCache = null;
|
||||
/**
|
||||
* 登記一個「不准出現在 trace 裡」的真身。
|
||||
* 非字串、空字串、純空白一律忽略(那些不是秘密,拿去比對只會誤傷)。
|
||||
*/
|
||||
add(value, label) {
|
||||
if (typeof value !== "string") return;
|
||||
if (value.trim().length === 0) return;
|
||||
if (this.labels.has(value)) return;
|
||||
this.labels.set(value, label);
|
||||
this.sortedCache = null;
|
||||
}
|
||||
/** 登記一整個 `{ name: 真身 }` map;label 由 name 決定。 */
|
||||
addRecord(record, label) {
|
||||
if (!record || typeof record !== "object") return;
|
||||
for (const [key, value] of Object.entries(record)) this.add(value, label(key));
|
||||
}
|
||||
/** 目前登記了幾個真身(0 = 這次執行沒用到任何 credential,redact 直接短路)。 */
|
||||
get size() {
|
||||
return this.labels.size;
|
||||
}
|
||||
/** 把一個字串裡所有登記過的真身換成標記。 */
|
||||
redactString(input) {
|
||||
if (this.labels.size === 0) return input;
|
||||
let out = input;
|
||||
for (const secret of this.sorted()) {
|
||||
const marker = redactionMarker(this.labels.get(secret));
|
||||
if (secret.length < MIN_SUBSTRING_LEN) {
|
||||
if (out === secret) out = marker;
|
||||
continue;
|
||||
}
|
||||
if (out.includes(secret)) out = out.split(secret).join(marker);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
/**
|
||||
* 深走一個值,回傳「同形狀但值被遮過」的副本。
|
||||
* 沒登記任何真身時原樣回傳同一個 reference(零成本,不影響 99% 的執行)。
|
||||
*/
|
||||
redact(value) {
|
||||
if (this.labels.size === 0) return value;
|
||||
return this.walk(value, /* @__PURE__ */ new WeakMap(), 0);
|
||||
}
|
||||
sorted() {
|
||||
if (this.sortedCache === null) {
|
||||
this.sortedCache = [...this.labels.keys()].sort((a, b) => b.length - a.length);
|
||||
}
|
||||
return this.sortedCache;
|
||||
}
|
||||
walk(value, seen, depth) {
|
||||
if (typeof value === "string") return this.redactString(value);
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
if (value instanceof Date) return value;
|
||||
if (depth >= MAX_DEPTH) return redactionMarker("depth-limit");
|
||||
const cached = seen.get(value);
|
||||
if (cached !== void 0) return cached;
|
||||
if (Array.isArray(value)) {
|
||||
const out2 = [];
|
||||
seen.set(value, out2);
|
||||
for (const item of value) out2.push(this.walk(item, seen, depth + 1));
|
||||
return out2;
|
||||
}
|
||||
const out = {};
|
||||
seen.set(value, out);
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
out[this.redactString(key)] = this.walk(child, seen, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// cypher-executor/src/graph-executor.ts
|
||||
function propagateCtx(context, upstreamResult, upstreamNodeId) {
|
||||
const baseCtx = typeof context === "object" && context !== null ? context : {};
|
||||
@@ -8383,6 +8538,7 @@ var init_graph_executor = __esm({
|
||||
init_paused_runs();
|
||||
init_magic_vars();
|
||||
init_telemetry();
|
||||
init_trace_redaction();
|
||||
GraphExecutor = class _GraphExecutor {
|
||||
loader;
|
||||
workflowLoader;
|
||||
@@ -8397,6 +8553,18 @@ var init_graph_executor = __esm({
|
||||
// 暫停時持久化 state 用,需在 execute 進入時設定
|
||||
currentGraph;
|
||||
currentRunId;
|
||||
// inkstone/Arcrun#197:本次執行解出來的 credential 真身名單。
|
||||
// 唯一用途=把值寫進「除錯面」(trace / failed_input / 錯誤訊息 / 回傳的 data)之前
|
||||
// 換成標記。每次 execute / resumeFromPaused 進入時重建,不跨執行殘留。
|
||||
//
|
||||
// 🔴 為什麼遮在這裡而不是在各個 route:trace 只有這一個產地,
|
||||
// 而它的消費端有五個(POST /execute、cypher-handlers、webhook-handlers、
|
||||
// GET /executions/:task_id、POST /workflows/resume)+ 一個持久化端(paused KV)。
|
||||
// 遮在產地=六個出口一次補齊;遮在出口=下一個新出口又會漏。
|
||||
redactor = new TraceRedactor();
|
||||
// inkstone/arcrun-rag#196:本次執行的 step-level 遙測先收在這裡,執行結束寫成一筆。
|
||||
// 原本每個 Component 節點各打一次 fetch,佔掉免費層每次呼叫 50 子請求的額度。
|
||||
nodeSteps = [];
|
||||
constructor(loader, workflowLoader, env, apiKey) {
|
||||
this.loader = loader;
|
||||
this.workflowLoader = workflowLoader;
|
||||
@@ -8405,6 +8573,7 @@ var init_graph_executor = __esm({
|
||||
}
|
||||
async execute(graph, initialContext, kvNamespace) {
|
||||
const trace = [];
|
||||
this.redactor = new TraceRedactor();
|
||||
const kvStore = kvNamespace ? { runId: `${graph.id}-${Date.now()}`, kv: kvNamespace } : void 0;
|
||||
this.currentGraph = graph;
|
||||
this.currentRunId = kvStore?.runId ?? `${graph.id}-${Date.now()}`;
|
||||
@@ -8424,11 +8593,17 @@ var init_graph_executor = __esm({
|
||||
fanIn.set(node.id, { ctx: { ...ctxWithMagic }, remaining: inDeg });
|
||||
}
|
||||
}
|
||||
const results = await Promise.all(
|
||||
startNodes.map(
|
||||
(node) => this.executeNode(node, graph, ctxWithMagic, /* @__PURE__ */ new Set(), trace, fanIn, kvStore)
|
||||
)
|
||||
);
|
||||
this.nodeSteps = [];
|
||||
let results;
|
||||
try {
|
||||
results = await Promise.all(
|
||||
startNodes.map(
|
||||
(node) => this.executeNode(node, graph, ctxWithMagic, /* @__PURE__ */ new Set(), trace, fanIn, kvStore)
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
this.flushNodeSteps(graph);
|
||||
}
|
||||
let mergedResult;
|
||||
if (results.length === 1) {
|
||||
mergedResult = results[0];
|
||||
@@ -8441,7 +8616,7 @@ var init_graph_executor = __esm({
|
||||
{}
|
||||
);
|
||||
}
|
||||
return { data: mergedResult, trace };
|
||||
return { data: this.redactor.redact(mergedResult), trace };
|
||||
}
|
||||
/**
|
||||
* 從 paused state 繼續執行 workflow
|
||||
@@ -8455,6 +8630,7 @@ var init_graph_executor = __esm({
|
||||
async resumeFromPaused(args) {
|
||||
const { graph, paused_node_id, paused_context, prior_trace, kvNamespace } = args;
|
||||
let { callback_result } = args;
|
||||
this.redactor = new TraceRedactor();
|
||||
callback_result = parseRecipeOutput(
|
||||
callback_result,
|
||||
args.recipe_output_format,
|
||||
@@ -8478,7 +8654,7 @@ var init_graph_executor = __esm({
|
||||
}
|
||||
const downstreamEdges = graph.edges.filter((e) => e.from === paused_node_id);
|
||||
if (downstreamEdges.length === 0) {
|
||||
return { data: callback_result, trace };
|
||||
return { data: this.redactor.redact(callback_result), trace };
|
||||
}
|
||||
const fanIn = /* @__PURE__ */ new Map();
|
||||
for (const node of graph.nodes) {
|
||||
@@ -8489,11 +8665,17 @@ var init_graph_executor = __esm({
|
||||
}
|
||||
const visited = /* @__PURE__ */ new Set([`${paused_node_id}:${JSON.stringify(paused_context).slice(0, 50)}`]);
|
||||
const downstreamNodes = downstreamEdges.map((e) => graph.nodes.find((n) => n.id === e.to)).filter((n) => !!n);
|
||||
const results = await Promise.all(
|
||||
downstreamNodes.map(
|
||||
(node) => this.executeNode(node, graph, mergedContext, visited, trace, fanIn, kvStore)
|
||||
)
|
||||
);
|
||||
this.nodeSteps = [];
|
||||
let results;
|
||||
try {
|
||||
results = await Promise.all(
|
||||
downstreamNodes.map(
|
||||
(node) => this.executeNode(node, graph, mergedContext, visited, trace, fanIn, kvStore)
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
this.flushNodeSteps(graph);
|
||||
}
|
||||
let mergedResult;
|
||||
if (results.length === 1) {
|
||||
mergedResult = results[0];
|
||||
@@ -8506,7 +8688,13 @@ var init_graph_executor = __esm({
|
||||
{}
|
||||
);
|
||||
}
|
||||
return { data: mergedResult, trace };
|
||||
return { data: this.redactor.redact(mergedResult), trace };
|
||||
}
|
||||
/** 把本次收集的 step-level 遙測寫成一筆(成功、失敗、暫停都寫)。 */
|
||||
flushNodeSteps(graph) {
|
||||
const steps = this.nodeSteps;
|
||||
this.nodeSteps = [];
|
||||
if (this.env && steps.length > 0) recordNodeSteps(this.env, this.apiKey, graph.name, steps);
|
||||
}
|
||||
async executeNode(node, graph, context, visited, trace, fanIn, kvStore) {
|
||||
const nodeKey = `${node.id}:${JSON.stringify(context).slice(0, 50)}`;
|
||||
@@ -8525,14 +8713,13 @@ var init_graph_executor = __esm({
|
||||
if (!node.componentId) throw new Error(`\u7BC0\u9EDE ${node.id} \u7F3A\u5C11 componentId`);
|
||||
const runner = await this.loader(node.componentId);
|
||||
const ctx = context;
|
||||
const resolvedData = interpolateData(node.data, ctx);
|
||||
const authoredData = node.data ?? {};
|
||||
const dataWithCredentials = this.env && this.apiKey ? await resolveCredentialRefs(authoredData, this.env, this.apiKey, this.redactor) : authoredData;
|
||||
const resolvedData = interpolateData(dataWithCredentials, ctx);
|
||||
let mergedContext = {
|
||||
...ctx,
|
||||
...resolvedData
|
||||
};
|
||||
if (this.env && this.apiKey) {
|
||||
mergedContext = await resolveCredentialRefs(mergedContext, this.env, this.apiKey);
|
||||
}
|
||||
if (node.componentId === "claude_api") {
|
||||
const baseUrl = this.env?.PUBLIC_BASE_URL ?? "https://cypher.arcrun.dev";
|
||||
mergedContext.callback_url = `${baseUrl.replace(/\/$/, "")}/workflows/resume`;
|
||||
@@ -8557,7 +8744,7 @@ var init_graph_executor = __esm({
|
||||
}
|
||||
}
|
||||
if (this.env && this.apiKey) {
|
||||
const dispatched = await tryAuthDispatch(node.componentId, mergedContext, this.env, this.apiKey);
|
||||
const dispatched = await tryAuthDispatch(node.componentId, mergedContext, this.env, this.apiKey, this.redactor);
|
||||
if (dispatched) {
|
||||
mergedContext = dispatched;
|
||||
}
|
||||
@@ -8576,8 +8763,8 @@ var init_graph_executor = __esm({
|
||||
trace.push({
|
||||
nodeId: node.id,
|
||||
type: node.type,
|
||||
input: nodeInput,
|
||||
output: result,
|
||||
input: this.redactor.redact(nodeInput),
|
||||
output: this.redactor.redact(result),
|
||||
duration_ms: Date.now() - start
|
||||
});
|
||||
await persistPausedRun(this.env.EXEC_CONTEXT, pending.task_id, {
|
||||
@@ -8612,30 +8799,25 @@ var init_graph_executor = __esm({
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof WorkflowPaused) throw e;
|
||||
const errMsg = e.message || String(e);
|
||||
const errMsg = this.redactor.redactString(e.message || String(e));
|
||||
const duration_ms2 = Date.now() - start;
|
||||
trace.push({
|
||||
nodeId: node.id,
|
||||
type: node.type,
|
||||
input: nodeInput,
|
||||
input: this.redactor.redact(nodeInput),
|
||||
output: null,
|
||||
error: errMsg,
|
||||
duration_ms: duration_ms2
|
||||
});
|
||||
if (this.env && node.type === "Component") {
|
||||
recordTelemetry(this.env, this.apiKey, {
|
||||
event_type: "node_failure",
|
||||
workflow_name: graph.name,
|
||||
component_id: node.componentId,
|
||||
error_code: "node_error",
|
||||
duration_ms: duration_ms2
|
||||
});
|
||||
if (node.type === "Component") {
|
||||
this.nodeSteps.push({ component_id: node.componentId, duration_ms: duration_ms2, ok: false, error_code: "node_error" });
|
||||
}
|
||||
if (e instanceof ExecutionError) throw e;
|
||||
throw new ExecutionError(
|
||||
`Node ${node.id} failed: ${errMsg}`,
|
||||
node.id,
|
||||
nodeInput,
|
||||
// #197:`failed_input` 直接進 HTTP 回應(execute.ts / cypher-handlers.ts)
|
||||
this.redactor.redact(nodeInput),
|
||||
trace
|
||||
);
|
||||
}
|
||||
@@ -8643,17 +8825,12 @@ var init_graph_executor = __esm({
|
||||
trace.push({
|
||||
nodeId: node.id,
|
||||
type: node.type,
|
||||
input: nodeInput,
|
||||
output: result,
|
||||
input: this.redactor.redact(nodeInput),
|
||||
output: this.redactor.redact(result),
|
||||
duration_ms
|
||||
});
|
||||
if (this.env && node.type === "Component") {
|
||||
recordTelemetry(this.env, this.apiKey, {
|
||||
event_type: "node_success",
|
||||
workflow_name: graph.name,
|
||||
component_id: node.componentId,
|
||||
duration_ms
|
||||
});
|
||||
if (node.type === "Component") {
|
||||
this.nodeSteps.push({ component_id: node.componentId, duration_ms, ok: true });
|
||||
}
|
||||
const outEdges = graph.edges.filter((e) => e.from === node.id);
|
||||
for (const edge of outEdges) {
|
||||
@@ -8860,6 +9037,17 @@ function componentVerdictsFromTrace(nodes, trace) {
|
||||
}
|
||||
return verdicts;
|
||||
}
|
||||
function aggregateVerdicts(verdicts) {
|
||||
const byId = /* @__PURE__ */ new Map();
|
||||
for (const v of verdicts) {
|
||||
const a = byId.get(v.component_id) ?? { component_id: v.component_id, runs: 0, success_runs: 0, duration_ms: 0 };
|
||||
a.runs += 1;
|
||||
a.success_runs += v.success ? 1 : 0;
|
||||
a.duration_ms += v.duration_ms;
|
||||
byId.set(v.component_id, a);
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
async function recordComponentStats(env, nodes, trace) {
|
||||
try {
|
||||
const base = (env.REGISTRY_BASE_URL ?? (env.WORKER_SUBDOMAIN ? wasmWorkerUrl("registry", env.WORKER_SUBDOMAIN) : void 0))?.replace(/\/$/, "");
|
||||
@@ -8867,14 +9055,17 @@ async function recordComponentStats(env, nodes, trace) {
|
||||
const verdicts = componentVerdictsFromTrace(nodes, trace);
|
||||
if (verdicts.length === 0) return;
|
||||
await Promise.all(
|
||||
verdicts.map(
|
||||
(v) => fetch(`${base}/analytics/record`, {
|
||||
aggregateVerdicts(verdicts).map(
|
||||
(a) => fetch(`${base}/analytics/record`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
canonical_id: v.component_id,
|
||||
success: v.success,
|
||||
duration_ms: v.duration_ms
|
||||
canonical_id: a.component_id,
|
||||
// 舊版 registry 不認 runs 時只會記 1 筆:給它「全部成功才算成功」的保守值
|
||||
success: a.success_runs === a.runs,
|
||||
duration_ms: a.duration_ms,
|
||||
runs: a.runs,
|
||||
success_runs: a.success_runs
|
||||
})
|
||||
}).catch(() => void 0)
|
||||
// 統計失敗不影響執行
|
||||
@@ -12906,6 +13097,53 @@ function generatePassword(length = 16) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// cypher-executor/src/lib/mcp-redirect-hosts.ts
|
||||
var MCP_BUILTIN_REDIRECT_HOSTS = ["claude.ai", "claude.com", "anthropic.com"];
|
||||
var MCP_REDIRECT_HOST_TEMPLATE = "portal_mcp_redirect_host";
|
||||
function normalizeRedirectHost(input) {
|
||||
const raw2 = String(input ?? "").trim();
|
||||
if (!raw2) return { ok: false, error: "\u8ACB\u586B\u5165\u7DB2\u5740\u6216\u7DB2\u57DF\uFF08\u4F8B\u5982 n8n.example.com\uFF09" };
|
||||
let host = raw2.toLowerCase();
|
||||
if (host.includes("://")) {
|
||||
let u;
|
||||
try {
|
||||
u = new URL(raw2);
|
||||
} catch {
|
||||
return { ok: false, error: `\u770B\u4E0D\u61C2\u9019\u500B\u7DB2\u5740\uFF1A${raw2}` };
|
||||
}
|
||||
if (u.protocol !== "https:" && u.protocol !== "http:") {
|
||||
return { ok: false, error: "\u53EA\u6536 https:// \u958B\u982D\u7684\u7DB2\u5740\uFF08\u672C\u6A5F\u6E2C\u8A66\u53EF\u7528 localhost\uFF09" };
|
||||
}
|
||||
host = u.hostname.toLowerCase();
|
||||
} else {
|
||||
host = host.split("/")[0].split("?")[0];
|
||||
if (host.includes("@")) return { ok: false, error: "\u8ACB\u4E0D\u8981\u5E36\u5E33\u865F\u5BC6\u78BC\uFF0C\u53EA\u8981\u7DB2\u57DF\u5C31\u597D" };
|
||||
if (host.startsWith("[")) return { ok: false, error: "\u4E0D\u652F\u63F4 IPv6 \u4F4D\u5740\uFF0C\u672C\u6A5F\u6E2C\u8A66\u8ACB\u586B localhost" };
|
||||
host = host.split(":")[0];
|
||||
}
|
||||
if (!host) return { ok: false, error: "\u8ACB\u586B\u5165\u7DB2\u5740\u6216\u7DB2\u57DF\uFF08\u4F8B\u5982 n8n.example.com\uFF09" };
|
||||
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/.test(host)) {
|
||||
return { ok: false, error: `\u300C${raw2}\u300D\u4E0D\u662F\u5408\u6CD5\u7684\u7DB2\u57DF\u3002\u4E0D\u63A5\u53D7\u842C\u7528\u5B57\u5143\uFF0C\u8ACB\u586B\u78BA\u5207\u7684\u7DB2\u57DF` };
|
||||
}
|
||||
const isLocal = host === "localhost" || host === "127.0.0.1";
|
||||
if (!isLocal && !host.includes(".")) {
|
||||
return { ok: false, error: `\u300C${host}\u300D\u770B\u8D77\u4F86\u4E0D\u662F\u5B8C\u6574\u7DB2\u57DF\uFF08\u5C11\u4E86 .com \u4E4B\u985E\u7684\u7D50\u5C3E\uFF09` };
|
||||
}
|
||||
if (host.length > 253) return { ok: false, error: "\u7DB2\u57DF\u592A\u9577" };
|
||||
if (!isLocal && host.split(".").length < 2) {
|
||||
return { ok: false, error: `\u300C${host}\u300D\u7BC4\u570D\u592A\u5927\uFF0C\u8ACB\u586B\u5B8C\u6574\u7DB2\u57DF` };
|
||||
}
|
||||
if (MCP_BUILTIN_REDIRECT_HOSTS.some((h) => host === h || host.endsWith("." + h))) {
|
||||
return { ok: false, error: `\u300C${host}\u300D\u662F\u5167\u5EFA\u5C31\u5141\u8A31\u7684\u7DB2\u57DF\uFF08Claude \u5B98\u65B9\uFF09\uFF0C\u4E0D\u5FC5\u518D\u52A0\u4E00\u6B21` };
|
||||
}
|
||||
return { ok: true, host };
|
||||
}
|
||||
function mcpUrlFor(subdomain) {
|
||||
const sub = String(subdomain ?? "").trim();
|
||||
if (!sub) return "";
|
||||
return `https://arcrun-mcp.${sub}.workers.dev/mcp`;
|
||||
}
|
||||
|
||||
// cypher-executor/src/lib/portal-seeds.ts
|
||||
var PORTAL_TEMPLATE_SEEDS = [
|
||||
{
|
||||
@@ -12938,6 +13176,20 @@ var PORTAL_TEMPLATE_SEEDS = [
|
||||
slots: ["name", "display_name", "description", "status", "graph_source", "root", "mode", "reason"],
|
||||
created_by: "system"
|
||||
},
|
||||
{
|
||||
// `inkstone/Arcrun#164`:「哪些網址可以接我的 MCP」——一個網域一筆 record。
|
||||
// 為什麼是 record 而不是一個字串設定:加了誰要留得下痕跡(本票紅線第三條),
|
||||
// 而「誰在什麼時候加的」是這個物件本身的屬性,不是一團 JSON(D91 同一條)。
|
||||
// host = 已正規化的小寫網域(lib/mcp-redirect-hosts.ts normalizeRedirectHost)
|
||||
// label = 使用者自己認得的名字(「我的 n8n」),純顯示用
|
||||
// created_at = ISO 時間字串
|
||||
// created_by = 加它的那個 portal 帳號 email
|
||||
// 🔴 不寫 KV(leo 2026-08-25 已禁長效用途),也不加 D1 表——這是 KBDB 萬用表的 template。
|
||||
name: "portal_mcp_redirect_host",
|
||||
description: "MCP OAuth \u5141\u8A31\u7684 redirect \u7DB2\u57DF\uFF08Arcrun#164\uFF1B\u4E00\u500B\u7DB2\u57DF\u4E00\u7B46\uFF0C\u53EF\u52A0\u53EF\u6536\u56DE\uFF09",
|
||||
slots: ["host", "label", "created_at", "created_by"],
|
||||
created_by: "system"
|
||||
},
|
||||
{
|
||||
// t130:rag_ingest_card.post_triplet 寫 POST /records {template:'triplet'}。
|
||||
// 新實例若無此 template 回 400「template not found: triplet」→ 三元組全滅。
|
||||
@@ -14462,6 +14714,106 @@ portalRouter.put(
|
||||
return c.json({ success: true, retention_days: data.retention_days ?? null });
|
||||
})
|
||||
);
|
||||
async function listMcpRedirectHosts(env) {
|
||||
const rows = await listRecordsByTemplate(env, MCP_REDIRECT_HOST_TEMPLATE);
|
||||
return rows.filter((r) => (r.values.host ?? "").trim() !== "").sort((a, b) => (a.values.host ?? "").localeCompare(b.values.host ?? ""));
|
||||
}
|
||||
portalRouter.get(
|
||||
"/portal/mcp-settings",
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const isAdmin = (auth.user.values.role ?? "") === "admin";
|
||||
const sub = String(c.env.WORKER_SUBDOMAIN ?? "").trim();
|
||||
const mcpUrl = mcpUrlFor(sub);
|
||||
const payload = {
|
||||
success: true,
|
||||
mcp_url: mcpUrl,
|
||||
// 誠實講「為什麼沒有」:這台實例的部署設定裡沒有 WORKER_SUBDOMAIN ⇒ 多半是安裝
|
||||
// 中途失敗(畫面卻說裝好了)。不要讓使用者以為是自己沒找到。
|
||||
mcp_url_reason: mcpUrl ? "" : "\u9019\u500B\u5BE6\u4F8B\u6C92\u6709\u8A18\u9304\u81EA\u5DF1\u7684\u90E8\u7F72\u4F4D\u7F6E\uFF08\u5B89\u88DD\u53EF\u80FD\u6C92\u6709\u5B8C\u6210\uFF09\uFF0C\u6240\u4EE5\u7B97\u4E0D\u51FA MCP \u7DB2\u5740",
|
||||
builtin_hosts: [...MCP_BUILTIN_REDIRECT_HOSTS],
|
||||
can_edit: isAdmin
|
||||
};
|
||||
if (isAdmin) {
|
||||
payload.hosts = (await listMcpRedirectHosts(c.env)).map((r) => ({
|
||||
record_id: r.record_id,
|
||||
host: r.values.host ?? "",
|
||||
label: r.values.label ?? "",
|
||||
created_at: r.values.created_at ?? "",
|
||||
created_by: r.values.created_by ?? ""
|
||||
}));
|
||||
}
|
||||
return c.json(payload);
|
||||
})
|
||||
);
|
||||
portalRouter.post(
|
||||
"/portal/admin/mcp-redirect-hosts",
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalAdmin(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const norm = normalizeRedirectHost(body?.host);
|
||||
if (!norm.ok) return c.json({ error: norm.error }, 400);
|
||||
const seeded = await ensurePortalTemplates(c.env);
|
||||
if (seeded.errors.length > 0) {
|
||||
return c.json({ error: `portal templates seed \u5931\u6557\uFF1A${seeded.errors.join("; ")}` }, 502);
|
||||
}
|
||||
const existing = await listMcpRedirectHosts(c.env);
|
||||
const dup = existing.find((r) => (r.values.host ?? "") === norm.host);
|
||||
if (dup) {
|
||||
return c.json({ success: true, already: true, host: norm.host, record_id: dup.record_id });
|
||||
}
|
||||
const ns = portalNamespace(c.env);
|
||||
const res = await kbdbFetch(c.env, "/records", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
template: MCP_REDIRECT_HOST_TEMPLATE,
|
||||
owner_id: ns,
|
||||
values: {
|
||||
host: norm.host,
|
||||
label: String(body?.label ?? "").trim().slice(0, 80),
|
||||
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
||||
created_by: auth.user.values.email ?? ""
|
||||
}
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new KbdbError(`POST /records\uFF08${MCP_REDIRECT_HOST_TEMPLATE}\uFF09\u2192 ${res.status}`);
|
||||
const created = await res.json().catch(() => null);
|
||||
return c.json({
|
||||
success: true,
|
||||
host: norm.host,
|
||||
record_id: created?.record_id ?? created?.record?.record_id ?? ""
|
||||
});
|
||||
})
|
||||
);
|
||||
portalRouter.delete(
|
||||
"/portal/admin/mcp-redirect-hosts/:id",
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalAdmin(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const recordId = c.req.param("id");
|
||||
const rows = await listMcpRedirectHosts(c.env);
|
||||
const target = rows.find((r) => r.record_id === recordId);
|
||||
if (!target) return c.json({ error: "\u9019\u500B\u7DB2\u57DF\u4E0D\u5728\u540D\u55AE\u4E0A\uFF08\u53EF\u80FD\u5DF2\u7D93\u88AB\u79FB\u9664\u4E86\uFF09" }, 404);
|
||||
const found = await deleteKbdbRecord(c.env, recordId);
|
||||
if (!found) return c.json({ error: "\u9019\u500B\u7DB2\u57DF\u4E0D\u5728\u540D\u55AE\u4E0A\uFF08\u53EF\u80FD\u5DF2\u7D93\u88AB\u79FB\u9664\u4E86\uFF09" }, 404);
|
||||
return c.json({ success: true, host: target.values.host ?? "" });
|
||||
})
|
||||
);
|
||||
portalRouter.get(
|
||||
"/portal/internal/mcp-redirect-hosts",
|
||||
(c) => run(c, async () => {
|
||||
const expected = c.env.KBDB_INTERNAL_TOKEN ?? "";
|
||||
if (!expected) {
|
||||
return c.json({ error: "\u9019\u53F0\u5BE6\u4F8B\u6C92\u6709\u8A2D\u5B9A\u670D\u52D9\u5167\u90E8\u91D1\u9470\uFF08KBDB_INTERNAL_TOKEN\uFF09\uFF0C\u7121\u6CD5\u56DE\u7B54" }, 503);
|
||||
}
|
||||
const got = (c.req.header("authorization") ?? "").match(/^Bearer\s+(\S+)/i)?.[1] ?? "";
|
||||
if (!got || !constantTimeEqual(got, expected)) return c.json({ error: "unauthorized" }, 401);
|
||||
const rows = await listMcpRedirectHosts(c.env);
|
||||
return c.json({ success: true, hosts: rows.map((r) => (r.values.host ?? "").trim()).filter(Boolean) });
|
||||
})
|
||||
);
|
||||
portalRouter.delete(
|
||||
"/portal/admin/libraries/by-name/:name",
|
||||
(c) => run(c, async () => {
|
||||
@@ -17288,11 +17640,12 @@ portalDataRouter.get(
|
||||
return c.json({ success: true, entry });
|
||||
})
|
||||
);
|
||||
async function fetchNeighborsFromKbdb(env, tenant2, node, depth, libraries) {
|
||||
async function fetchNeighborsFromKbdb(env, tenant2, node, depth, libraries, directed) {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("depth", String(depth));
|
||||
qs.set("template", "triplet");
|
||||
if (!libraries.includes("*")) qs.set("library", libraries.join(","));
|
||||
if (directed) qs.set("directed", "true");
|
||||
const res = await kbdbFetch(env, `/graph/neighbors/${encodeURIComponent(node)}?${qs.toString()}&${ownerQuery(tenant2)}`);
|
||||
const body = await res.json().catch(() => null);
|
||||
return { ok: res.ok, status: res.status, body };
|
||||
@@ -17310,13 +17663,14 @@ portalDataRouter.get(
|
||||
const rawName = c.req.param("name");
|
||||
const depthRaw = c.req.query("depth") ?? "";
|
||||
const depth = /^\d{1,2}$/.test(depthRaw) ? Number(depthRaw) : 2;
|
||||
const directed = c.req.query("directed") === "true";
|
||||
const tryNames = [rawName];
|
||||
const normalized = normalizeCjkQuery(rawName);
|
||||
if (normalized !== rawName) tryNames.push(normalized);
|
||||
let first = null;
|
||||
try {
|
||||
for (const name of tryNames) {
|
||||
const r = await fetchNeighborsFromKbdb(c.env, tenant2, name, depth, libraries);
|
||||
const r = await fetchNeighborsFromKbdb(c.env, tenant2, name, depth, libraries, directed);
|
||||
if (!first) first = r;
|
||||
if (!r.ok) break;
|
||||
const mapped = mapGraphNeighborsResponse(r.body);
|
||||
@@ -17325,7 +17679,7 @@ portalDataRouter.get(
|
||||
if (first?.ok) {
|
||||
const fallbackName = await fuzzyFindNode(c.env, tenant2, rawName, libraries);
|
||||
if (fallbackName && !tryNames.includes(fallbackName)) {
|
||||
const r = await fetchNeighborsFromKbdb(c.env, tenant2, fallbackName, depth, libraries);
|
||||
const r = await fetchNeighborsFromKbdb(c.env, tenant2, fallbackName, depth, libraries, directed);
|
||||
if (r.ok) {
|
||||
const mapped = mapGraphNeighborsResponse(r.body);
|
||||
if (mapped.count > 0) return c.json(mapped);
|
||||
|
||||
+219
-24
@@ -2124,6 +2124,10 @@ var UNLABELLED_LIBRARY = "general";
|
||||
function libraryOf(expr) {
|
||||
return `COALESCE(NULLIF(${expr}, ''), '${UNLABELLED_LIBRARY}')`;
|
||||
}
|
||||
function libraryOfValue(value) {
|
||||
const v = (value ?? "").trim();
|
||||
return v === "" ? UNLABELLED_LIBRARY : v;
|
||||
}
|
||||
var ENTRY_LIBRARY_EXPR = "json_extract(metadata_json, '$.library')";
|
||||
var ENTRY_LIBRARY = libraryOf(ENTRY_LIBRARY_EXPR);
|
||||
var ENTRY_UNLABELLED = `(${ENTRY_LIBRARY_EXPR} IS NULL OR ${ENTRY_LIBRARY_EXPR} = '')`;
|
||||
@@ -2164,21 +2168,25 @@ async function getEntry(db, id) {
|
||||
return row ?? null;
|
||||
}
|
||||
var NOT_MACHINERY_PREDICATE = "(src_id IS NULL AND entry_type NOT IN ('record', 'sheet', 'field', 'system'))";
|
||||
function eqTerm(column, exactKeyPresent) {
|
||||
return exactKeyPresent ? `+${column} = ?` : `${column} = ?`;
|
||||
}
|
||||
async function listEntries(db, f = {}) {
|
||||
const conds = [];
|
||||
const params = [];
|
||||
const exact = Boolean(f.page_name || f.source);
|
||||
if (f.entry_type) {
|
||||
conds.push("entry_type = ?");
|
||||
conds.push(eqTerm("entry_type", exact));
|
||||
params.push(f.entry_type);
|
||||
} else {
|
||||
conds.push(NOT_MACHINERY_PREDICATE);
|
||||
}
|
||||
if (f.owner_id) {
|
||||
conds.push("owner_id = ?");
|
||||
conds.push(eqTerm("owner_id", exact));
|
||||
params.push(f.owner_id);
|
||||
}
|
||||
if (f.parent_id) {
|
||||
conds.push("parent_id = ?");
|
||||
conds.push(eqTerm("parent_id", exact));
|
||||
params.push(f.parent_id);
|
||||
}
|
||||
if (f.page_name) {
|
||||
@@ -2206,11 +2214,17 @@ async function listEntries(db, f = {}) {
|
||||
const where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
|
||||
const limit = Math.min(f.limit ?? 100, 1e3);
|
||||
const offset = f.offset ?? 0;
|
||||
const [rowsRes, countRow] = await Promise.all([
|
||||
db.prepare(`SELECT * FROM entries ${where} ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`).bind(...params, limit, offset).all(),
|
||||
db.prepare(`SELECT COUNT(*) as total FROM entries ${where}`).bind(...params).first()
|
||||
]);
|
||||
return { entries: rowsRes.results ?? [], total: countRow?.total ?? 0 };
|
||||
const pageSizeKnown = Number.isFinite(limit) && limit > 0;
|
||||
const rowsRes = await db.prepare(`SELECT * FROM entries ${where} ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`).bind(...params, limit, offset).all();
|
||||
const entries = rowsRes.results ?? [];
|
||||
let total;
|
||||
if (pageSizeKnown && entries.length > 0 && entries.length < limit) total = offset + entries.length;
|
||||
else if (pageSizeKnown && entries.length === 0 && offset === 0) total = 0;
|
||||
else {
|
||||
const countRow = await db.prepare(`SELECT COUNT(*) as total FROM entries ${where}`).bind(...params).first();
|
||||
total = countRow?.total ?? 0;
|
||||
}
|
||||
return { entries, total };
|
||||
}
|
||||
async function blocksOfPages(db, pages, perPageLimit = 8) {
|
||||
if (pages.length === 0) return [];
|
||||
@@ -3521,6 +3535,58 @@ entryRoutes.delete("/:id", async (c) => {
|
||||
return c.json({ success: true, vector_deleted });
|
||||
});
|
||||
|
||||
// kbdb/src/actions/entity-canon.ts
|
||||
var NOTE_EXT = /\.(md|markdown|mdx|txt|org)$/i;
|
||||
function unwrapWhole(t) {
|
||||
const code = /^(`+)([\s\S]*?)(`+)$/.exec(t);
|
||||
if (code) {
|
||||
const inner = code[2].trim();
|
||||
if (inner && !inner.includes("`")) return inner;
|
||||
}
|
||||
if (t.startsWith("[[") && t.endsWith("]]") && t.length > 4) {
|
||||
const inner = t.slice(2, -2).trim();
|
||||
if (inner && !inner.includes("[[") && !inner.includes("]]")) return inner;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
function canonicalEntity(raw2) {
|
||||
if (typeof raw2 !== "string") return raw2;
|
||||
const fallback = raw2.trim();
|
||||
let t = raw2.normalize("NFC").trim();
|
||||
if (!t) return fallback;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const before = t;
|
||||
t = unwrapWhole(t);
|
||||
if (t === before) break;
|
||||
}
|
||||
if (NOTE_EXT.test(t)) {
|
||||
t = t.replace(NOTE_EXT, "");
|
||||
const cut = Math.max(t.lastIndexOf("/"), t.lastIndexOf("\\"));
|
||||
if (cut >= 0) t = t.slice(cut + 1);
|
||||
}
|
||||
t = t.replace(/\s+/g, " ").trim();
|
||||
return t || fallback;
|
||||
}
|
||||
var ENTITY_SLOTS = ["subject", "object"];
|
||||
function isTripletShaped(slots) {
|
||||
return slots.includes("subject") && slots.includes("predicate") && slots.includes("object");
|
||||
}
|
||||
function canonicalizeEntityValues(slots, values) {
|
||||
if (!isTripletShaped(slots)) return values;
|
||||
let touched = false;
|
||||
const out = { ...values };
|
||||
for (const slot of ENTITY_SLOTS) {
|
||||
const v = out[slot];
|
||||
if (typeof v !== "string") continue;
|
||||
const c = canonicalEntity(v);
|
||||
if (c !== v) {
|
||||
out[slot] = c;
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
return touched ? out : values;
|
||||
}
|
||||
|
||||
// kbdb/src/actions/record-crud.ts
|
||||
function uid2(prefix) {
|
||||
return `${prefix}_${crypto.randomUUID()}`;
|
||||
@@ -3622,7 +3688,7 @@ async function createRecord(db, input) {
|
||||
if (!tpl) throw new Error(`template not found: ${input.template}`);
|
||||
const slots = JSON.parse(tpl.slots_json);
|
||||
const recordId = input.record_id ?? uid2("rec");
|
||||
const values = input.values ?? {};
|
||||
const values = canonicalizeEntityValues(slots, input.values ?? {});
|
||||
const entryIds = input.entry_ids ?? {};
|
||||
const refSlots = Object.keys(entryIds);
|
||||
const ownerId = input.owner_id ?? null;
|
||||
@@ -3673,7 +3739,8 @@ async function updateRecord(db, recordId, values) {
|
||||
const recordOwnerId = identity?.owner_id ?? null;
|
||||
const tpl = await getTemplate(db, templateId);
|
||||
const allowed = tpl ? JSON.parse(tpl.slots_json) : [...slotToEntries.keys()];
|
||||
for (const [slot, content] of Object.entries(values)) {
|
||||
const canon = canonicalizeEntityValues(allowed, values);
|
||||
for (const [slot, content] of Object.entries(canon)) {
|
||||
if (!allowed.includes(slot)) {
|
||||
throw new Error(`slot not in template: ${slot}`);
|
||||
}
|
||||
@@ -3780,13 +3847,19 @@ async function deleteRecord(db, recordId) {
|
||||
}
|
||||
|
||||
// kbdb/src/routes/templates.ts
|
||||
function readFields(body) {
|
||||
if (!body) return void 0;
|
||||
const raw2 = Array.isArray(body.slots) ? body.slots : Array.isArray(body.fields) ? body.fields : void 0;
|
||||
return raw2;
|
||||
}
|
||||
var templateRoutes = new Hono2();
|
||||
templateRoutes.post("/", async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || !body.name || !Array.isArray(body.slots)) {
|
||||
return c.json({ success: false, error: "name and slots[] required" }, 400);
|
||||
const fields = readFields(body);
|
||||
if (!body || !body.name || !Array.isArray(fields)) {
|
||||
return c.json({ success: false, error: "name and slots[] (alias: fields[]) required" }, 400);
|
||||
}
|
||||
const tpl = await createTemplate(c.env.DB, body);
|
||||
const tpl = await createTemplate(c.env.DB, { ...body, slots: fields });
|
||||
return c.json({ success: true, template: tpl });
|
||||
});
|
||||
templateRoutes.get("/", async (c) => {
|
||||
@@ -3800,7 +3873,11 @@ templateRoutes.get("/:idOrName", async (c) => {
|
||||
});
|
||||
templateRoutes.patch("/:id", async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const tpl = await updateTemplate(c.env.DB, c.req.param("id"), body);
|
||||
const fields = readFields(body);
|
||||
const tpl = await updateTemplate(c.env.DB, c.req.param("id"), {
|
||||
...body,
|
||||
...fields === void 0 ? {} : { slots: fields }
|
||||
});
|
||||
if (!tpl) return c.json({ success: false, error: "not found" }, 404);
|
||||
return c.json({ success: true, template: tpl });
|
||||
});
|
||||
@@ -4615,14 +4692,18 @@ async function findTripletEdgesByNode(db, templateIdOrName, fields, nodeValue, o
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
function neighborToEdge(n) {
|
||||
return n.direction === "in" ? { subject: n.node, predicate: n.predicate, object: n.from } : { subject: n.from, predicate: n.predicate, object: n.node };
|
||||
}
|
||||
async function graphNeighbors(db, start, opts = {}) {
|
||||
const depth = Math.max(1, Math.min(Math.floor(opts.depth ?? 1) || 1, 10));
|
||||
const template = opts.template ?? "triplet";
|
||||
const directed = !!opts.directed;
|
||||
const owner_id = opts.owner_id;
|
||||
const libraries = opts.library && opts.library.length > 0 ? opts.library : void 0;
|
||||
const visited = /* @__PURE__ */ new Set([start]);
|
||||
let frontier = [start];
|
||||
const startNode = canonicalEntity(start);
|
||||
const visited = /* @__PURE__ */ new Set([startNode]);
|
||||
let frontier = [startNode];
|
||||
const neighbors = [];
|
||||
for (let d = 1; d <= depth; d++) {
|
||||
if (frontier.length === 0) break;
|
||||
@@ -4634,7 +4715,7 @@ async function graphNeighbors(db, start, opts = {}) {
|
||||
const nb = e.object;
|
||||
if (!nb || visited.has(nb)) continue;
|
||||
visited.add(nb);
|
||||
neighbors.push({ node: nb, predicate: e.predicate ?? "", from: cur, depth: d });
|
||||
neighbors.push({ node: nb, predicate: e.predicate ?? "", from: cur, depth: d, direction: "out" });
|
||||
next.push(nb);
|
||||
}
|
||||
if (!directed) {
|
||||
@@ -4644,18 +4725,125 @@ async function graphNeighbors(db, start, opts = {}) {
|
||||
const nb = e.subject;
|
||||
if (!nb || visited.has(nb)) continue;
|
||||
visited.add(nb);
|
||||
neighbors.push({ node: nb, predicate: e.predicate ?? "", from: cur, depth: d });
|
||||
neighbors.push({ node: nb, predicate: e.predicate ?? "", from: cur, depth: d, direction: "in" });
|
||||
next.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
return { success: true, start, depth, directed, libraries: libraries ?? null, neighbors, count: neighbors.length };
|
||||
return { success: true, start: startNode, depth, directed, libraries: libraries ?? null, neighbors, count: neighbors.length };
|
||||
}
|
||||
|
||||
// kbdb/src/actions/entity-canon-backfill.ts
|
||||
var HARD_LIMIT_CAP2 = 2e3;
|
||||
var DEFAULT_LIMIT = 500;
|
||||
async function canonicalizeTripletEntities(db, env, opts = {}) {
|
||||
const templateName = opts.template ?? "triplet";
|
||||
const dryRun = opts.dry_run !== false;
|
||||
const limit = Math.min(Math.max(opts.limit ?? DEFAULT_LIMIT, 1), HARD_LIMIT_CAP2);
|
||||
const offset = Math.max(opts.offset ?? 0, 0);
|
||||
const ownerId = opts.owner_id?.trim() || void 0;
|
||||
const tpl = await getTemplate(db, templateName);
|
||||
if (!tpl) throw new Error(`triplet template not found: ${templateName}`);
|
||||
const slots = JSON.parse(tpl.slots_json);
|
||||
if (!isTripletShaped(slots)) {
|
||||
throw new Error(`template is not triplet-shaped (need subject/predicate/object): ${templateName}`);
|
||||
}
|
||||
const sql = `SELECT b.src_id AS rid,
|
||||
MAX(CASE WHEN r.rel_id = ? THEN r.dst_id END) AS subject_eid,
|
||||
MAX(CASE WHEN r.rel_id = ? THEN v.content END) AS subject,
|
||||
MAX(CASE WHEN r.rel_id = ? THEN r.dst_id END) AS object_eid,
|
||||
MAX(CASE WHEN r.rel_id = ? THEN v.content END) AS object,
|
||||
MAX(CASE WHEN r.rel_id = ? THEN v.content END) AS library
|
||||
FROM entries b
|
||||
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
|
||||
LEFT JOIN entries v ON v.id = r.dst_id
|
||||
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${ownerId ? " AND b.owner_id = ?" : ""}
|
||||
GROUP BY b.src_id
|
||||
ORDER BY b.src_id
|
||||
LIMIT ? OFFSET ?`;
|
||||
const sField = fieldEntryId(tpl.id, "subject");
|
||||
const oField = fieldEntryId(tpl.id, "object");
|
||||
const lField = fieldEntryId(tpl.id, "library");
|
||||
const params = [sField, sField, oField, oField, lField, tpl.id];
|
||||
if (ownerId) params.push(ownerId);
|
||||
params.push(limit, offset);
|
||||
const res = await db.prepare(sql).bind(...params).all();
|
||||
const rows = res.results ?? [];
|
||||
const rewrites = /* @__PURE__ */ new Map();
|
||||
const groups = /* @__PURE__ */ new Map();
|
||||
let scannedCells = 0;
|
||||
for (const row of rows) {
|
||||
const lib = libraryOfValue(row.library);
|
||||
for (const slot of ENTITY_SLOTS) {
|
||||
const raw2 = slot === "subject" ? row.subject : row.object;
|
||||
const eid = slot === "subject" ? row.subject_eid : row.object_eid;
|
||||
if (typeof raw2 !== "string" || !raw2 || !eid) continue;
|
||||
scannedCells++;
|
||||
const canon = canonicalEntity(raw2);
|
||||
const key = `${lib}\0${canon}`;
|
||||
const g = groups.get(key) ?? { library: lib, canonical: canon, from: /* @__PURE__ */ new Set(), cells: 0 };
|
||||
g.from.add(raw2);
|
||||
g.cells++;
|
||||
groups.set(key, g);
|
||||
if (canon !== raw2) rewrites.set(eid, canon);
|
||||
}
|
||||
}
|
||||
const changedCells = rewrites.size;
|
||||
const merges = [...groups.values()].filter((g) => g.from.size > 1).map((g) => ({ library: g.library, canonical: g.canonical, from: [...g.from].sort(), cells: g.cells })).sort((a, b) => b.from.length - a.from.length || a.canonical.localeCompare(b.canonical));
|
||||
const budget = await maintenanceBudgetToday(env, db);
|
||||
let applied = 0;
|
||||
let quotaExceeded = false;
|
||||
if (!dryRun && changedCells > 0) {
|
||||
const entries = [...rewrites.entries()].slice(0, budget.remaining);
|
||||
quotaExceeded = entries.length < changedCells;
|
||||
if (entries.length > 0) {
|
||||
await db.batch(
|
||||
entries.map(
|
||||
([eid, content]) => db.prepare("UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?").bind(content, eid)
|
||||
)
|
||||
);
|
||||
applied = entries.length;
|
||||
}
|
||||
try {
|
||||
await addMaintenanceUsage(db, applied);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
return {
|
||||
dry_run: dryRun,
|
||||
triplet_template: templateName,
|
||||
scanned_records: rows.length,
|
||||
scanned_cells: scannedCells,
|
||||
changed_cells: changedCells,
|
||||
applied_cells: applied,
|
||||
merges,
|
||||
quota_limit: budget.limit,
|
||||
quota_used_today: budget.used + applied,
|
||||
quota_exceeded: quotaExceeded,
|
||||
// 這一批滿了就還有下一批;沒滿就是掃到底了(誠實回 null,不要讓呼叫端自己猜)
|
||||
next_offset: rows.length === limit ? offset + limit : null
|
||||
};
|
||||
}
|
||||
|
||||
// kbdb/src/routes/graph.ts
|
||||
var graphRoutes = new Hono2();
|
||||
graphRoutes.post("/canonicalize-entities", async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
try {
|
||||
const result = await canonicalizeTripletEntities(c.env.DB, c.env, {
|
||||
template: typeof body.template === "string" ? body.template : void 0,
|
||||
owner_id: typeof body.owner_id === "string" ? body.owner_id : void 0,
|
||||
dry_run: body.dry_run === false ? false : true,
|
||||
limit: typeof body.limit === "number" ? body.limit : void 0,
|
||||
offset: typeof body.offset === "number" ? body.offset : void 0
|
||||
});
|
||||
return c.json({ success: true, ...result });
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
|
||||
}
|
||||
});
|
||||
graphRoutes.get("/neighbors/:node", async (c) => {
|
||||
const node = c.req.param("node");
|
||||
if (!node) return c.json({ success: false, error: "node required" }, 400);
|
||||
@@ -4667,11 +4855,7 @@ graphRoutes.get("/neighbors/:node", async (c) => {
|
||||
const library = parseLibraryList(c.req.query("library"));
|
||||
try {
|
||||
const result = await graphNeighbors(c.env.DB, node, { depth, template, directed, owner_id, library });
|
||||
const edges = result.neighbors.map((n) => ({
|
||||
subject: n.from,
|
||||
predicate: n.predicate,
|
||||
object: n.node
|
||||
}));
|
||||
const edges = result.neighbors.map(neighborToEdge);
|
||||
return c.json({ ...result, edges });
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 500);
|
||||
@@ -4971,6 +5155,16 @@ var GENERATIONS = [
|
||||
file: "0008_entries_content_index.sql",
|
||||
what: "entries.content \u7D22\u5F15\u2014\u2014graph \u9130\u5C45\u67E5\u8A62\u5F9E\u7BC0\u9EDE\u540D\u76F4\u63A5\u67E5\uFF08\u4E0D\u6488\u5168\u8868\uFF09\uFF0CArcrun#168 \u6839\u56E0\u4FEE\u5FA9\u7684\u5730\u57FA",
|
||||
checks: [{ kind: "index", name: "idx_entries_content" }]
|
||||
},
|
||||
{
|
||||
n: 9,
|
||||
file: "0009_entries_list_indexes.sql",
|
||||
what: "entries \u5217\u8868\uFF0F\u5B58\u5728\u6027\u7D22\u5F15\u2014\u2014list \u7AEF\u9EDE\u4E0D\u518D\u6392\u5E8F\u6574\u500B owner\u3001\u300C\u9019\u5F35\u5361\u5728\u4E0D\u5728\u300D\u53EA\u8B80\u547D\u4E2D\u5217\uFF08Arcrun#210 D1 \u514D\u8CBB\u5C64\u4E09\u5929\u9023\u71D2\u7684\u6839\u56E0\u4FEE\u5FA9\uFF09",
|
||||
checks: [
|
||||
{ kind: "index", name: "idx_entries_owner_type_created" },
|
||||
{ kind: "index", name: "idx_entries_owner_created" },
|
||||
{ kind: "index", name: "idx_entries_source" }
|
||||
]
|
||||
}
|
||||
];
|
||||
var EXPECTED_GENERATION = GENERATIONS[GENERATIONS.length - 1].n;
|
||||
@@ -5124,6 +5318,7 @@ app.get("/maintenance/relation-orphans", async (c) => {
|
||||
});
|
||||
app.route("/entries", entryRoutes);
|
||||
app.route("/templates", templateRoutes);
|
||||
app.route("/sheets", templateRoutes);
|
||||
app.route("/records", recordRoutes);
|
||||
app.route("/recipe-stats", recipeStatRoutes);
|
||||
app.route("/execution-log", executionLogRoutes);
|
||||
|
||||
+169
-37
@@ -31796,11 +31796,11 @@ function registerAllKbdbDataTools(server, env, identity) {
|
||||
function registerCreateTemplate(server, env, identity) {
|
||||
server.tool(
|
||||
"kbdb_create_template",
|
||||
"\u5EFA\u4E00\u500B KBDB template\uFF08\u842C\u7528\u8868\u88E1\u7684\u4E00\u7A2E\u8CC7\u6599\u5F62\u72C0\uFF0C\u985E Supabase \u7684\u865B\u64EC\u8868\uFF09\u3002KBDB \u4E0D\u80FD\u5EFA\u771F\u7684\u8CC7\u6599\u8868\u2014\u2014\u8981\u5B58\u300C\u65B0\u985E\u578B\u300D\u7684\u7D50\u69CB\u5316\u8CC7\u6599\u6642\uFF0C\u5C31\u5EFA\u4E00\u500B template \u4E26\u7528 slots \u5217\u51FA\u5B83\u7684\u6B04\u4F4D\u540D\uFF0C\u4E4B\u5F8C\u7528 kbdb_create_record \u586B\u503C\u3002\u4F8B\uFF1Aname='contact', slots=['name','email','phone']\u3002",
|
||||
"\u5728 KBDB \u5EFA\u4E00\u5F35**\u865B\u64EC\u8868**\uFF08\u5BE6\u4F5C\u88E1\u53EB sheet\uFF09\u3002KBDB \u6C38\u9060\u4E0D\u52A0\u771F\u7684\u8CC7\u6599\u8868\uFF0C\u4E5F\u4E0D\u63A5\u53D7 SQL\u2014\u2014\u8981\u5B58\u300C\u65B0\u985E\u578B\u300D\u7684\u7D50\u69CB\u5316\u8CC7\u6599\u6642\uFF0C\u5C31\u5EFA\u4E00\u5F35\u865B\u64EC\u8868\uFF0C\u7528 slots \u5217\u51FA\u5B83\u7684**\u865B\u64EC\u6B04\u4F4D**\u540D\uFF0C\u4E4B\u5F8C\u7528 kbdb_create_record \u586B\u503C\u3002\u4F8B\uFF1Aname='contact', slots=['name','email','phone']\u3002\u{1F534} \u60F3\u5230 CREATE TABLE / ALTER TABLE \u5C31\u662F\u7528\u932F\u6A5F\u5236\u4E86\uFF1A\u6B63\u89E3\u6C38\u9060\u662F\u9019\u652F\u5DE5\u5177\u3002",
|
||||
{
|
||||
name: external_exports.string().min(1).describe("template \u540D\u7A31\uFF08\u552F\u4E00\u8B58\u5225\uFF0C\u4E4B\u5F8C\u586B record \u7528\u9019\u500B\u540D\u5B57\uFF09\uFF0C\u5982 'contact' / 'note'"),
|
||||
slots: external_exports.array(external_exports.string().min(1)).min(1).describe("\u6B04\u4F4D\u540D\u6E05\u55AE\uFF0C\u5982 ['name','email','phone']"),
|
||||
description: external_exports.string().optional().describe("\u9019\u500B template \u7528\u9014\u7684\u7C21\u8FF0\uFF08\u9078\u586B\uFF09"),
|
||||
name: external_exports.string().min(1).describe("\u865B\u64EC\u8868\u7684\u540D\u5B57\uFF08\u552F\u4E00\u8B58\u5225\uFF0C\u4E4B\u5F8C\u586B record \u7528\u9019\u500B\u540D\u5B57\uFF09\uFF0C\u5982 'contact' / 'note'"),
|
||||
slots: external_exports.array(external_exports.string().min(1)).min(1).describe("**\u865B\u64EC\u6B04\u4F4D**\u540D\u6E05\u55AE\uFF0C\u5982 ['name','email','phone']\uFF08\u53C3\u6578\u540D slots \u662F\u6B77\u53F2\u6CBF\u7528\uFF0C\u4E0D\u662F\u90A3\u5F35\u4E0D\u5B58\u5728\u7684 slots \u8868\uFF09"),
|
||||
description: external_exports.string().optional().describe("\u9019\u5F35\u865B\u64EC\u8868\u7528\u9014\u7684\u7C21\u8FF0\uFF08\u9078\u586B\uFF09"),
|
||||
created_by: external_exports.string().optional().describe("\u5EFA\u7ACB\u8005\u6A19\u8A18\uFF08\u9078\u586B\uFF1B\u767B\u5165\u8EAB\u5206\u4E0B\u7531 server \u8A18\u9304\uFF0C\u4E0D\u5403\u6B64\u503C\uFF09")
|
||||
},
|
||||
async ({ name, slots, description, created_by }) => {
|
||||
@@ -31831,7 +31831,7 @@ function registerCreateTemplate(server, env, identity) {
|
||||
function registerListTemplates(server, env, identity) {
|
||||
server.tool(
|
||||
"kbdb_list_templates",
|
||||
"\u5217\u51FA KBDB \u88E1\u6240\u6709 template\uFF08\u5DF2\u5B9A\u7FA9\u7684\u8CC7\u6599\u5F62\u72C0\uFF09\u3002\u8981\u5B58\u8CC7\u6599\u524D\u5148\u770B\u6709\u6C92\u6709\u73FE\u6210 template \u53EF\u7528\uFF0C\u6C92\u6709\u518D kbdb_create_template\u3002",
|
||||
"\u5217\u51FA KBDB \u88E1\u6240\u6709**\u865B\u64EC\u8868**\uFF08\u5DF2\u5B9A\u7FA9\u7684\u8CC7\u6599\u5F62\u72C0\uFF09\u3002\u8981\u5B58\u8CC7\u6599\u524D\u5148\u770B\u6709\u6C92\u6709\u73FE\u6210\u7684\u53EF\u7528\uFF0C\u6C92\u6709\u518D kbdb_create_template\u3002\u56DE\u61C9\u88E1\u6BCF\u5F35\u8868\u7684 slots_json \u5C31\u662F\u5B83\u7684**\u865B\u64EC\u6B04\u4F4D**\u6E05\u55AE\u3002",
|
||||
{},
|
||||
async () => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
@@ -31843,9 +31843,9 @@ function registerListTemplates(server, env, identity) {
|
||||
}
|
||||
const data = await res.json();
|
||||
return successResponse(data, [
|
||||
"\u6BCF\u500B template \u7684 slots_json \u662F\u5B83\u7684\u6B04\u4F4D\u6E05\u55AE",
|
||||
"\u6BCF\u5F35\u865B\u64EC\u8868\u7684 slots_json \u662F\u5B83\u7684\u865B\u64EC\u6B04\u4F4D\u6E05\u55AE",
|
||||
"\u586B\u8CC7\u6599\u7528 kbdb_create_record",
|
||||
"template \u662F\u5168\u57DF\u5171\u4EAB\u7684\u300C\u8CC7\u6599\u5F62\u72C0\u300D\u5B9A\u7FA9\uFF08schema\uFF09\uFF0C\u4E0D\u542B\u4EFB\u4F55\u4EBA\u7684\u5167\u5BB9\u2014\u2014\u5167\u5BB9\u7684\u6B0A\u9650\u5728 record/entry \u90A3\u5C64"
|
||||
"\u865B\u64EC\u8868\u662F\u5168\u57DF\u5171\u4EAB\u7684\u300C\u8CC7\u6599\u5F62\u72C0\u300D\u5B9A\u7FA9\uFF0C\u4E0D\u542B\u4EFB\u4F55\u4EBA\u7684\u5167\u5BB9\u2014\u2014\u5167\u5BB9\u7684\u6B0A\u9650\u5728 record/entry \u90A3\u5C64"
|
||||
]);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["\u7A0D\u5F8C\u91CD\u8A66"]);
|
||||
@@ -31856,10 +31856,10 @@ function registerListTemplates(server, env, identity) {
|
||||
function registerCreateRecord(server, env, identity) {
|
||||
server.tool(
|
||||
"kbdb_create_record",
|
||||
"\u4F9D\u67D0 template \u586B\u4E00\u7B46 record\uFF08\u4E00\u5217\u8CC7\u6599\uFF09\u3002values \u662F {slot\u540D: \u5167\u5BB9}\uFF0Cslot \u540D\u8981\u5C0D\u5F97\u4E0A template \u7684 slots\u3002template \u4E0D\u5B58\u5728\u6703\u5931\u6557\u2014\u2014\u5148 kbdb_list_templates \u78BA\u8A8D\uFF0C\u6216 kbdb_create_template \u5EFA\u4E00\u500B\u3002",
|
||||
"\u4F9D\u67D0\u5F35**\u865B\u64EC\u8868**\u586B\u4E00\u5217\u8CC7\u6599\uFF08record\uFF09\u3002values \u662F {\u865B\u64EC\u6B04\u4F4D\u540D: \u5167\u5BB9}\uFF0C\u6B04\u4F4D\u540D\u8981\u5C0D\u5F97\u4E0A\u90A3\u5F35\u8868\u5BA3\u544A\u7684\u6B04\u4F4D\u3002\u865B\u64EC\u8868\u4E0D\u5B58\u5728\u6703\u5931\u6557\u2014\u2014\u5148 kbdb_list_templates \u78BA\u8A8D\uFF0C\u6216 kbdb_create_template \u5EFA\u4E00\u5F35\u3002\u26A0\uFE0F values \u88E1**\u6C92\u5BA3\u544A\u904E\u7684\u6B04\u4F4D\u540D\u6703\u88AB\u975C\u9ED8\u4E1F\u6389**\uFF08\u56DE 200 \u4F46\u90A3\u683C\u6C92\u5BEB\u9032\u53BB\uFF09\u21D2 \u5BEB\u5B8C\u7528 kbdb_get_record \u8B80\u56DE\u4F86\u6838\u5C0D\u3002",
|
||||
{
|
||||
template: external_exports.string().min(1).describe("template \u7684 name \u6216 id"),
|
||||
values: external_exports.record(external_exports.string()).describe("\u6B04\u4F4D\u5167\u5BB9 {slot\u540D: \u5B57\u4E32\u5167\u5BB9}\uFF0C\u5982 {name:'Leo', email:'leo@x.com'}"),
|
||||
template: external_exports.string().min(1).describe("\u865B\u64EC\u8868\u7684 name \u6216 id"),
|
||||
values: external_exports.record(external_exports.string()).describe("\u6B04\u4F4D\u5167\u5BB9 {\u865B\u64EC\u6B04\u4F4D\u540D: \u5B57\u4E32\u5167\u5BB9}\uFF0C\u5982 {name:'Leo', email:'leo@x.com'}"),
|
||||
owner_id: external_exports.string().optional().describe("\u8CC7\u6599\u6B78\u5C6C\u6A19\u8A18\uFF08\u9078\u586B\uFF1B\u767B\u5165\u8EAB\u5206\u4E0B\u4E00\u5F8B\u7531 server \u5B9A\u6210\u4F60\u7684\u6B78\u5C6C\uFF0C\u4E0D\u5403\u6B64\u503C\uFF09")
|
||||
},
|
||||
async ({ template, values, owner_id }) => {
|
||||
@@ -31876,13 +31876,13 @@ function registerCreateRecord(server, env, identity) {
|
||||
if (!res.ok) {
|
||||
if (identity.kind === "portal") return portalError(res, `\u586B record\uFF08template\u300C${template}\u300D\uFF09`);
|
||||
return errorResponse("create_record_failed", `\u586B record \u5931\u6557`, [
|
||||
`\u78BA\u8A8D template\u300C${template}\u300D\u5B58\u5728\uFF08kbdb_list_templates\uFF09`,
|
||||
"values \u7684 slot \u540D\u8981\u5C0D\u5F97\u4E0A template \u7684 slots"
|
||||
`\u78BA\u8A8D\u865B\u64EC\u8868\u300C${template}\u300D\u5B58\u5728\uFF08kbdb_list_templates\uFF09`,
|
||||
"values \u7684\u6B04\u4F4D\u540D\u8981\u5C0D\u5F97\u4E0A\u90A3\u5F35\u865B\u64EC\u8868\u5BA3\u544A\u7684\u865B\u64EC\u6B04\u4F4D"
|
||||
], await res.text().catch(() => ""));
|
||||
}
|
||||
const data = await res.json();
|
||||
return successResponse(data, [
|
||||
`\u5DF2\u5B58\u5165\u3002\u7528 kbdb_query(template='${template}') \u5217\u51FA\u6B64 template \u7684\u6240\u6709 record`,
|
||||
`\u5DF2\u5B58\u5165\u3002\u7528 kbdb_query(template='${template}') \u5217\u51FA\u9019\u5F35\u865B\u64EC\u8868\u7684\u6240\u6709 record\uFF1B\u7528 kbdb_get_record \u8B80\u56DE\u4F86\u6838\u5C0D`,
|
||||
...identity.kind === "portal" ? [OWNER_IGNORED_HINT] : []
|
||||
]);
|
||||
} catch (e) {
|
||||
@@ -31905,7 +31905,7 @@ function registerGetRecord(server, env, identity) {
|
||||
if (res.status === 404) {
|
||||
return errorResponse("not_found", `\u67E5\u7121 record\u300C${record_id}\u300D\uFF08\u4E0D\u5B58\u5728\uFF0C\u6216\u4E0D\u5728\u4F60\u7684\u6B0A\u9650\u7BC4\u570D\u5167\uFF09`, [
|
||||
"\u78BA\u8A8D record_id \u6B63\u78BA",
|
||||
"\u7528 kbdb_query \u5217\u51FA\u67D0 template \u7684 record \u53D6 id"
|
||||
"\u7528 kbdb_query \u5217\u51FA\u67D0\u5F35\u865B\u64EC\u8868\u7684 record \u53D6 id"
|
||||
]);
|
||||
}
|
||||
if (!res.ok) {
|
||||
@@ -31923,9 +31923,9 @@ function registerGetRecord(server, env, identity) {
|
||||
function registerQuery(server, env, identity) {
|
||||
server.tool(
|
||||
"kbdb_query",
|
||||
"\u5217\u51FA\u67D0 template \u5E95\u4E0B\u7684 record\uFF08\u7D50\u69CB\u5316\u67E5\u8A62\uFF0C\u6309 template \u53D6\u6574\u6279\u8CC7\u6599\uFF09\u3002**\u6703\u5206\u9801**\uFF1A\u56DE\u61C9\u7684 total \u662F\u7B26\u5408\u689D\u4EF6\u7684\u5168\u90E8\u7B46\u6578\u3001count \u662F\u9019\u4E00\u9801\u62FF\u5230\u5E7E\u7B46\u2014\u2014total \u6BD4\u5DF2\u53D6\u5F97\u7684\u591A\u5C31\u5E36 offset \u518D\u53EB\u4E00\u6B21\uFF0C\u4E0D\u8981\u628A\u7B2C\u4E00\u9801\u7576\u6210\u5168\u90E8\u3002\u8981\u6309\u95DC\u9375\u5B57\u627E\u5167\u5BB9\u7528 kbdb_search\u3002",
|
||||
"\u5217\u51FA\u67D0\u5F35**\u865B\u64EC\u8868**\u5E95\u4E0B\u7684 record\uFF08\u7D50\u69CB\u5316\u67E5\u8A62\uFF0C\u6309\u865B\u64EC\u8868\u53D6\u6574\u6279\u8CC7\u6599\uFF09\u3002**\u6703\u5206\u9801**\uFF1A\u56DE\u61C9\u7684 total \u662F\u7B26\u5408\u689D\u4EF6\u7684\u5168\u90E8\u7B46\u6578\u3001count \u662F\u9019\u4E00\u9801\u62FF\u5230\u5E7E\u7B46\u2014\u2014total \u6BD4\u5DF2\u53D6\u5F97\u7684\u591A\u5C31\u5E36 offset \u518D\u53EB\u4E00\u6B21\uFF0C\u4E0D\u8981\u628A\u7B2C\u4E00\u9801\u7576\u6210\u5168\u90E8\u3002\u8981\u6309\u95DC\u9375\u5B57\u627E\u5167\u5BB9\u7528 kbdb_search\u3002",
|
||||
{
|
||||
template: external_exports.string().min(1).describe("template \u7684 name \u6216 id"),
|
||||
template: external_exports.string().min(1).describe("\u865B\u64EC\u8868\u7684 name \u6216 id"),
|
||||
owner_id: external_exports.string().optional().describe("\u53EA\u53D6\u67D0\u6B78\u5C6C\u7684 record\uFF08\u9078\u586B\uFF1B\u767B\u5165\u8EAB\u5206\u4E0B\u4E0D\u751F\u6548\uFF0C\u7BC4\u570D\u7531\u4F60\u7684\u6B0A\u9650\u6C7A\u5B9A\uFF09"),
|
||||
limit: external_exports.number().int().positive().optional().describe("\u9019\u4E00\u9801\u8981\u5E7E\u7B46\uFF08\u9810\u8A2D 100\uFF0C\u55AE\u6B21\u4E0A\u9650 500\uFF09"),
|
||||
offset: external_exports.number().int().min(0).optional().describe("\u5F9E\u7B2C\u5E7E\u7B46\u958B\u59CB\uFF08\u5206\u9801\u7528\uFF0C\u9810\u8A2D 0\uFF09")
|
||||
@@ -32051,7 +32051,10 @@ function registerGraphNeighbors(server, env, orgNamespace, identity) {
|
||||
env,
|
||||
identity.portal.session,
|
||||
`/portal/data/graph/neighbors/${encodeURIComponent(subject)}`,
|
||||
{ query: { depth: depth ?? 1 } }
|
||||
// 🔴 inkstone/Arcrun#175:portal 路徑原本只送 depth,directed 被丟掉 ⇒ 引擎永遠當
|
||||
// 無向查詢,directed=true 也會回反向邊。這裡把它補上(服務級 token 路徑早就送了,
|
||||
// 見下方 `if (directed) query.directed = "true"`)。未帶=不送=維持無向(criterion 4)。
|
||||
{ query: { depth: depth ?? 1, ...directed ? { directed: "true" } : {} } }
|
||||
);
|
||||
if (!res.ok) return portalError(res, `\u67E5\u300C${subject}\u300D\u7684\u9130\u5C45`);
|
||||
const out = await res.json().catch(() => null);
|
||||
@@ -33396,10 +33399,125 @@ function consentPage(p, error2) {
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
function redirectBlockedPage(p) {
|
||||
let host = "";
|
||||
try {
|
||||
host = p.redirectUri ? new URL(p.redirectUri).hostname : "";
|
||||
} catch {
|
||||
host = "";
|
||||
}
|
||||
const what = host ? `<p class="desc">\u4F60\u7684 AI \u5DE5\u5177\u8981\u6C42\u628A\u6388\u6B0A\u7D50\u679C\u9001\u56DE <code>${esc2(host)}</code>\uFF0C
|
||||
\u4F46\u9019\u500B\u7DB2\u57DF<strong>\u4E0D\u5728\u9019\u500B\u77E5\u8B58\u5EAB\u5141\u8A31\u7684\u540D\u55AE\u4E0A</strong>\u3002</p>` : `<p class="desc">\u4F60\u7684 AI \u5DE5\u5177\u6C92\u6709\u5E36\u56DE\u9023\u7DDA\u7DB2\u5740\uFF08<code>redirect_uri</code>\uFF09\uFF0C
|
||||
\u6216\u5E36\u7684\u683C\u5F0F\u4E0D\u6B63\u78BA\uFF0C\u6240\u4EE5\u7121\u6CD5\u78BA\u8A8D\u8981\u628A\u6388\u6B0A\u7D50\u679C\u9001\u56DE\u54EA\u88E1\u3002</p>`;
|
||||
const list = p.allowed.length ? `<ul>${p.allowed.map((h) => `<li><code>${esc2(h)}</code></li>`).join("")}</ul>` : `<p class="desc">\uFF08\u76EE\u524D\u4E00\u500B\u90FD\u6C92\u6709\uFF09</p>`;
|
||||
const portalLink = p.portalUrl ? `<p><a href="${esc2(p.portalUrl)}">\u6253\u958B\u8A2D\u5B9A\u9801 \u2192</a></p>` : "";
|
||||
return `<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Arcrun MCP \u6388\u6B0A \u2014 \u9019\u500B\u7DB2\u57DF\u9084\u6C92\u88AB\u5141\u8A31</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { font-family: -apple-system, "Segoe UI", system-ui, sans-serif; max-width: 32rem;
|
||||
margin: 4rem auto; padding: 0 1.25rem; line-height: 1.7; }
|
||||
h1 { font-size: 1.25rem; }
|
||||
h2 { font-size: 1rem; margin-top: 2rem; }
|
||||
p.desc { color: #666; font-size: .95rem; }
|
||||
code { background: rgba(127,127,127,.15); padding: .1rem .35rem; border-radius: .25rem;
|
||||
font-size: .85rem; word-break: break-all; }
|
||||
ul { padding-left: 1.2rem; }
|
||||
ol { padding-left: 1.4rem; }
|
||||
p.foot { color: #999; font-size: .8rem; margin-top: 2.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>\u9019\u500B\u7DB2\u57DF\u9084\u6C92\u88AB\u5141\u8A31\u9023\u4E0A\u4F60\u7684\u77E5\u8B58\u5EAB</h1>
|
||||
${what}
|
||||
<h2>\u600E\u9EBC\u89E3\u6C7A</h2>
|
||||
<ol>
|
||||
<li>\u7528\u700F\u89BD\u5668\u767B\u5165\u4F60\u7684\u77E5\u8B58\u5EAB <strong>Portal</strong>\uFF08\u7BA1\u7406\u54E1\u5E33\u865F\uFF09</li>
|
||||
<li>\u5230 <strong>\u8A2D\u5B9A \u2192 \u63A5\u4E0A\u4F60\u7684 AI\uFF08MCP\uFF09</strong></li>
|
||||
<li>\u5728<strong>\u300C\u5141\u8A31\u9023\u7DDA\u7684\u7DB2\u5740\u300D</strong>\u628A${host ? ` <code>${esc2(host)}</code> ` : "\u90A3\u500B\u5DE5\u5177\u7D66\u4F60\u7684 callback \u7DB2\u5740"}\u52A0\u9032\u53BB</li>
|
||||
<li>\u56DE\u5230\u525B\u624D\u90A3\u500B\u5DE5\u5177\uFF0C\u91CD\u65B0\u6309\u4E00\u6B21\u6388\u6B0A</li>
|
||||
</ol>
|
||||
${portalLink}
|
||||
<h2>\u76EE\u524D\u5141\u8A31\u7684\u7DB2\u57DF</h2>
|
||||
${list}
|
||||
<p class="foot">\u64CB\u4E0B\u9019\u6B21\u9023\u7DDA\u7684\u662F<strong>\u4F60\u81EA\u5DF1\u7684 Arcrun \u77E5\u8B58\u5EAB</strong>\uFF08\u4E0D\u662F Claude\u3001\u4E0D\u662F\u4F60\u7684 AI \u5DE5\u5177\uFF09\u3002
|
||||
\u9019\u9053\u6AA2\u67E5\u662F\u70BA\u4E86\u9632\u6B62\u6709\u4EBA\u628A\u6388\u6B0A\u7D50\u679C\u5C0E\u53BB\u5225\u7684\u7DB2\u7AD9\uFF0C\u6240\u4EE5\u9810\u8A2D\u53EA\u653E\u884C Claude \u5B98\u65B9\u7DB2\u57DF\uFF1B
|
||||
\u5176\u9918\u8981\u7531\u4F60\u81EA\u5DF1\u52A0\u3002<br>
|
||||
\u6280\u8853\u8A0A\u606F\uFF08\u56DE\u5831\u6642\u9644\u4E0A\uFF09\uFF1A<code>invalid_request: redirect_uri missing or not allowed</code></p>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// mcp/src/oauth/allowed-hosts.ts
|
||||
var DEFAULT_REDIRECT_HOSTS = ["claude.ai", "claude.com", "anthropic.com"];
|
||||
var HOSTS_CACHE_TTL_MS = 15e3;
|
||||
var hostsCache = null;
|
||||
function parseHostList(raw2) {
|
||||
return String(raw2 ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
function union2(...lists) {
|
||||
const seen = /* @__PURE__ */ new Set();
|
||||
const out = [];
|
||||
for (const list of lists) {
|
||||
for (const h of list) {
|
||||
const k = h.trim().toLowerCase();
|
||||
if (!k || seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(k);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
async function fetchPortalHosts(env) {
|
||||
if (!env.CYPHER_EXECUTOR || !env.KBDB_INTERNAL_TOKEN) return null;
|
||||
try {
|
||||
const res = await env.CYPHER_EXECUTOR.fetch(
|
||||
new Request("https://cypher/portal/internal/mcp-redirect-hosts", {
|
||||
headers: { Authorization: `Bearer ${env.KBDB_INTERNAL_TOKEN}` }
|
||||
})
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const body = await res.json();
|
||||
if (!Array.isArray(body.hosts)) return null;
|
||||
return body.hosts.filter((h) => typeof h === "string");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function allowedRedirectHosts(env) {
|
||||
const now = Date.now();
|
||||
if (hostsCache && now - hostsCache.at < HOSTS_CACHE_TTL_MS) return hostsCache.hosts;
|
||||
const fromEnv = parseHostList(env.MCP_ALLOWED_REDIRECT_HOSTS);
|
||||
const fromPortal = await fetchPortalHosts(env) ?? [];
|
||||
const hosts = union2(DEFAULT_REDIRECT_HOSTS, fromEnv, fromPortal);
|
||||
hostsCache = { at: now, hosts };
|
||||
return hosts;
|
||||
}
|
||||
function hostMatches(host, hosts) {
|
||||
const h = host.toLowerCase();
|
||||
return hosts.some((allowed) => h === allowed || h.endsWith("." + allowed));
|
||||
}
|
||||
function isAllowedRedirect(uri, hosts) {
|
||||
let u;
|
||||
try {
|
||||
u = new URL(uri);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const host = u.hostname.toLowerCase();
|
||||
const isLocal = host === "localhost" || host === "127.0.0.1" || host === "::1";
|
||||
if (u.protocol === "http:") return isLocal;
|
||||
if (u.protocol !== "https:") return false;
|
||||
if (isLocal) return true;
|
||||
return hostMatches(host, hosts);
|
||||
}
|
||||
|
||||
// mcp/src/oauth/routes.ts
|
||||
var DEFAULT_TOKEN_TTL = 2592e3;
|
||||
var DEFAULT_REDIRECT_HOSTS = ["claude.ai", "claude.com", "anthropic.com"];
|
||||
var CORS_JSON = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
@@ -33414,21 +33532,15 @@ function tokenTtl(env) {
|
||||
const n = parseInt(env.MCP_TOKEN_TTL ?? "", 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_TOKEN_TTL;
|
||||
}
|
||||
function isAllowedRedirect(uri, env) {
|
||||
let u;
|
||||
function portalUrlFromOrigin(origin) {
|
||||
try {
|
||||
u = new URL(uri);
|
||||
const u = new URL(origin);
|
||||
if (!u.hostname.startsWith("arcrun-mcp.")) return "";
|
||||
u.hostname = u.hostname.replace("arcrun-mcp.", "arcrun-rag-ui.");
|
||||
return u.origin + "/portal/";
|
||||
} catch {
|
||||
return false;
|
||||
return "";
|
||||
}
|
||||
const host = u.hostname.toLowerCase();
|
||||
const isLocal = host === "localhost" || host === "127.0.0.1" || host === "::1";
|
||||
if (u.protocol === "http:") return isLocal;
|
||||
if (u.protocol !== "https:") return false;
|
||||
if (isLocal) return true;
|
||||
const configured = (env.MCP_ALLOWED_REDIRECT_HOSTS ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
const list = configured.length ? configured : DEFAULT_REDIRECT_HOSTS;
|
||||
return list.some((h) => host === h || host.endsWith("." + h));
|
||||
}
|
||||
async function readParams(req) {
|
||||
const ct = req.headers.get("content-type") ?? "";
|
||||
@@ -33471,10 +33583,14 @@ function registerOAuthRoutes(app2) {
|
||||
}
|
||||
const redirectUris = Array.isArray(raw2.redirect_uris) ? raw2.redirect_uris.filter((x) => typeof x === "string") : [];
|
||||
const clientName = typeof raw2.client_name === "string" ? raw2.client_name : "MCP Client";
|
||||
const hosts = await allowedRedirectHosts(c.env);
|
||||
for (const uri of redirectUris) {
|
||||
if (!isAllowedRedirect(uri, c.env)) {
|
||||
if (!isAllowedRedirect(uri, hosts)) {
|
||||
return c.json(
|
||||
{ error: "invalid_redirect_uri", error_description: `redirect_uri not allowed: ${uri}` },
|
||||
{
|
||||
error: "invalid_redirect_uri",
|
||||
error_description: `redirect_uri not allowed: ${uri}\u3002\u9019\u500B\u7DB2\u57DF\u4E0D\u5728\u4F60\u7684\u77E5\u8B58\u5EAB\u5141\u8A31\u7684\u540D\u55AE\u4E0A\uFF0C\u8ACB\u767B\u5165 Portal \u7684\u300C\u8A2D\u5B9A \u2192 \u63A5\u4E0A\u4F60\u7684 AI\uFF08MCP\uFF09\u300D\u628A\u5B83\u52A0\u9032\u53BB\u3002\u76EE\u524D\u5141\u8A31\uFF1A${hosts.join(", ")}`
|
||||
},
|
||||
400,
|
||||
CORS_JSON
|
||||
);
|
||||
@@ -33495,7 +33611,7 @@ function registerOAuthRoutes(app2) {
|
||||
CORS_JSON
|
||||
);
|
||||
});
|
||||
app2.get("/authorize", (c) => {
|
||||
app2.get("/authorize", async (c) => {
|
||||
const q = c.req.query();
|
||||
if (q.response_type !== "code") {
|
||||
return c.text("unsupported_response_type: only 'code' is supported", 400);
|
||||
@@ -33503,8 +33619,16 @@ function registerOAuthRoutes(app2) {
|
||||
if (q.code_challenge_method !== "S256" || !q.code_challenge) {
|
||||
return c.text("invalid_request: PKCE S256 code_challenge required", 400);
|
||||
}
|
||||
if (!q.redirect_uri || !isAllowedRedirect(q.redirect_uri, c.env)) {
|
||||
return c.text("invalid_request: redirect_uri missing or not allowed", 400);
|
||||
const hosts = await allowedRedirectHosts(c.env);
|
||||
if (!q.redirect_uri || !isAllowedRedirect(q.redirect_uri, hosts)) {
|
||||
return c.html(
|
||||
redirectBlockedPage({
|
||||
redirectUri: q.redirect_uri ?? "",
|
||||
allowed: hosts,
|
||||
portalUrl: portalUrlFromOrigin(originOf(c.req.url))
|
||||
}),
|
||||
400
|
||||
);
|
||||
}
|
||||
const canonicalResource = resourceUri(originOf(c.req.url));
|
||||
if (q.resource && !resourceMatches(q.resource, originOf(c.req.url))) {
|
||||
@@ -33532,8 +33656,16 @@ function registerOAuthRoutes(app2) {
|
||||
app2.post("/authorize", async (c) => {
|
||||
const p = await readParams(c.req.raw);
|
||||
const redirectUri = p.redirect_uri ?? "";
|
||||
if (!redirectUri || !isAllowedRedirect(redirectUri, c.env)) {
|
||||
return c.text("invalid_request: redirect_uri not allowed", 400);
|
||||
const hosts = await allowedRedirectHosts(c.env);
|
||||
if (!redirectUri || !isAllowedRedirect(redirectUri, hosts)) {
|
||||
return c.html(
|
||||
redirectBlockedPage({
|
||||
redirectUri,
|
||||
allowed: hosts,
|
||||
portalUrl: portalUrlFromOrigin(originOf(c.req.url))
|
||||
}),
|
||||
400
|
||||
);
|
||||
}
|
||||
if (p.code_challenge_method !== "S256" || !p.code_challenge) {
|
||||
return c.text("invalid_request: PKCE S256 required", 400);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Executable
BIN
Binary file not shown.
+48
-45
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schema": 2,
|
||||
"built": "2026-08-29",
|
||||
"source": "Arcrun@e92d6e683271",
|
||||
"built": "2026-09-13",
|
||||
"source": "Arcrun@1eb26c98a3af",
|
||||
"core": [
|
||||
{
|
||||
"name": "arcrun-array-ops",
|
||||
@@ -198,7 +198,7 @@
|
||||
"name": "arcrun-cypher-executor",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-cypher-executor/worker.mjs",
|
||||
"js_bytes": 703085,
|
||||
"js_bytes": 721657,
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
@@ -239,10 +239,10 @@
|
||||
"stripped": {
|
||||
"services": 13
|
||||
},
|
||||
"source_commit": "f08274db7874d3c9ba6dba927cf58b272390daa4",
|
||||
"source_content_sha256": "be1d58410009b640bf800e13b07db6a95ab0deff991315ec436ff6f1147771be",
|
||||
"sha256": "be1d58410009b640bf800e13b07db6a95ab0deff991315ec436ff6f1147771be",
|
||||
"bytes": 703085
|
||||
"source_commit": "1eb26c98a3af2067303e7544ae9bca1a77867c32",
|
||||
"source_content_sha256": "2e61c3ebc75761463e79e1740da65971a9111aa1675442d8742a372816551591",
|
||||
"sha256": "2e61c3ebc75761463e79e1740da65971a9111aa1675442d8742a372816551591",
|
||||
"bytes": 721657
|
||||
},
|
||||
{
|
||||
"name": "arcrun-date-ops",
|
||||
@@ -396,7 +396,7 @@
|
||||
"name": "arcrun-kbdb",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-kbdb/worker.mjs",
|
||||
"js_bytes": 195715,
|
||||
"js_bytes": 203823,
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
@@ -416,16 +416,16 @@
|
||||
"ENVIRONMENT": "production"
|
||||
}
|
||||
},
|
||||
"source_commit": "98b45409740bdb4976f8744f09255c98be8fc10c",
|
||||
"source_content_sha256": "fc1b12b56c7b3f8d68021349c72b5301afbb93333bf25887595b0fedc0e2232d",
|
||||
"sha256": "fc1b12b56c7b3f8d68021349c72b5301afbb93333bf25887595b0fedc0e2232d",
|
||||
"bytes": 195715
|
||||
"source_commit": "bed24fbbd5c3befed02c5690133d640f1d036e56",
|
||||
"source_content_sha256": "8df57bc74d8f9c828db40207fd0079ae060374be27c0dc9cab835959aee091b0",
|
||||
"sha256": "8df57bc74d8f9c828db40207fd0079ae060374be27c0dc9cab835959aee091b0",
|
||||
"bytes": 203823
|
||||
},
|
||||
{
|
||||
"name": "arcrun-mcp",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-mcp/worker.mjs",
|
||||
"js_bytes": 1208080,
|
||||
"js_bytes": 1215339,
|
||||
"modules": [],
|
||||
"compat_date": "2024-11-27",
|
||||
"compat_flags": [
|
||||
@@ -440,10 +440,10 @@
|
||||
"ai": false,
|
||||
"vars": {}
|
||||
},
|
||||
"source_commit": "f87260b1234f0c108c378caf474d7af78cdd6cb9",
|
||||
"source_content_sha256": "fad73a5128b50f8f42317ea1c4844ea39eee3c32846ab061282e77b731d810d0",
|
||||
"sha256": "fad73a5128b50f8f42317ea1c4844ea39eee3c32846ab061282e77b731d810d0",
|
||||
"bytes": 1208080
|
||||
"source_commit": "5dd01c41cc58d84bd0d854af083c48b657f77bc0",
|
||||
"source_content_sha256": "e3190ad33e01abbe58de09a48d85af46129386bad1a52cf1eb9ce1aeb41b50af",
|
||||
"sha256": "e3190ad33e01abbe58de09a48d85af46129386bad1a52cf1eb9ce1aeb41b50af",
|
||||
"bytes": 1215339
|
||||
},
|
||||
{
|
||||
"name": "arcrun-merge",
|
||||
@@ -507,7 +507,7 @@
|
||||
"name": "arcrun-rag-ui",
|
||||
"main_module": "index.js",
|
||||
"main_file": "tier2/ui/index.js",
|
||||
"js_bytes": 715537,
|
||||
"js_bytes": 723651,
|
||||
"modules": [],
|
||||
"compat_date": "2026-07-01",
|
||||
"compat_flags": [],
|
||||
@@ -518,10 +518,10 @@
|
||||
"ai": false,
|
||||
"vars": {}
|
||||
},
|
||||
"source_commit": "e92d6e6832716b633e02cdfc2d73aba966c34c90",
|
||||
"source_content_sha256": "d1f71a84caa3e40b1a546588b3f86bab145e8dde9edbb68c299e56a65c61825e",
|
||||
"sha256": "d1f71a84caa3e40b1a546588b3f86bab145e8dde9edbb68c299e56a65c61825e",
|
||||
"bytes": 715537
|
||||
"source_commit": "5dd01c41cc58d84bd0d854af083c48b657f77bc0",
|
||||
"source_content_sha256": "9dfa16ae18c83b2a73689e0df9fabb34c65b82978a929927fa7fb9d1afb3e022",
|
||||
"sha256": "9dfa16ae18c83b2a73689e0df9fabb34c65b82978a929927fa7fb9d1afb3e022",
|
||||
"bytes": 723651
|
||||
},
|
||||
{
|
||||
"name": "arcrun-set",
|
||||
@@ -698,12 +698,12 @@
|
||||
"bytes": 67697
|
||||
}
|
||||
],
|
||||
"release": "1.4.63",
|
||||
"release": "1.4.64",
|
||||
"built_for": "oauth-installer-lazy-load",
|
||||
"notes": [
|
||||
"rag_takedown_direct 用了 __CARDS_PREFIX__,但安裝器的代換表沒有它 ⇒ 這個佔位符會原封不動被推進使用者的工作流。"
|
||||
],
|
||||
"fingerprint": "56d038b0d50909f375cc0b15dfc51364014d6b581c30c1d65bd0c8876aa2880c",
|
||||
"fingerprint": "a925c1bd35c86d5305d96b07bd9d43839de4f179fd94acc8cd24370f54952d7a",
|
||||
"library": [
|
||||
{
|
||||
"name": "arcrun-array-ops",
|
||||
@@ -888,7 +888,7 @@
|
||||
"name": "arcrun-cypher-executor",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-cypher-executor/worker.mjs",
|
||||
"js_bytes": 703085,
|
||||
"js_bytes": 721657,
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
@@ -929,8 +929,8 @@
|
||||
"stripped": {
|
||||
"services": 13
|
||||
},
|
||||
"source_commit": "f08274db7874d3c9ba6dba927cf58b272390daa4",
|
||||
"source_content_sha256": "be1d58410009b640bf800e13b07db6a95ab0deff991315ec436ff6f1147771be",
|
||||
"source_commit": "1eb26c98a3af2067303e7544ae9bca1a77867c32",
|
||||
"source_content_sha256": "2e61c3ebc75761463e79e1740da65971a9111aa1675442d8742a372816551591",
|
||||
"first_install": true
|
||||
},
|
||||
{
|
||||
@@ -1075,7 +1075,7 @@
|
||||
"name": "arcrun-kbdb",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-kbdb/worker.mjs",
|
||||
"js_bytes": 195715,
|
||||
"js_bytes": 203823,
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
@@ -1095,15 +1095,15 @@
|
||||
"ENVIRONMENT": "production"
|
||||
}
|
||||
},
|
||||
"source_commit": "98b45409740bdb4976f8744f09255c98be8fc10c",
|
||||
"source_content_sha256": "fc1b12b56c7b3f8d68021349c72b5301afbb93333bf25887595b0fedc0e2232d",
|
||||
"source_commit": "bed24fbbd5c3befed02c5690133d640f1d036e56",
|
||||
"source_content_sha256": "8df57bc74d8f9c828db40207fd0079ae060374be27c0dc9cab835959aee091b0",
|
||||
"first_install": true
|
||||
},
|
||||
{
|
||||
"name": "arcrun-mcp",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-mcp/worker.mjs",
|
||||
"js_bytes": 1208080,
|
||||
"js_bytes": 1215339,
|
||||
"modules": [],
|
||||
"compat_date": "2024-11-27",
|
||||
"compat_flags": [
|
||||
@@ -1118,8 +1118,8 @@
|
||||
"ai": false,
|
||||
"vars": {}
|
||||
},
|
||||
"source_commit": "f87260b1234f0c108c378caf474d7af78cdd6cb9",
|
||||
"source_content_sha256": "fad73a5128b50f8f42317ea1c4844ea39eee3c32846ab061282e77b731d810d0",
|
||||
"source_commit": "5dd01c41cc58d84bd0d854af083c48b657f77bc0",
|
||||
"source_content_sha256": "e3190ad33e01abbe58de09a48d85af46129386bad1a52cf1eb9ce1aeb41b50af",
|
||||
"first_install": true
|
||||
},
|
||||
{
|
||||
@@ -1180,7 +1180,7 @@
|
||||
"name": "arcrun-rag-ui",
|
||||
"main_module": "index.js",
|
||||
"main_file": "tier2/ui/index.js",
|
||||
"js_bytes": 715537,
|
||||
"js_bytes": 723651,
|
||||
"modules": [],
|
||||
"compat_date": "2026-07-01",
|
||||
"compat_flags": [],
|
||||
@@ -1191,8 +1191,8 @@
|
||||
"ai": false,
|
||||
"vars": {}
|
||||
},
|
||||
"source_commit": "e92d6e6832716b633e02cdfc2d73aba966c34c90",
|
||||
"source_content_sha256": "d1f71a84caa3e40b1a546588b3f86bab145e8dde9edbb68c299e56a65c61825e",
|
||||
"source_commit": "5dd01c41cc58d84bd0d854af083c48b657f77bc0",
|
||||
"source_content_sha256": "9dfa16ae18c83b2a73689e0df9fabb34c65b82978a929927fa7fb9d1afb3e022",
|
||||
"first_install": true
|
||||
},
|
||||
{
|
||||
@@ -1489,20 +1489,23 @@
|
||||
}
|
||||
],
|
||||
"daemon": {
|
||||
"version": "0.18.49",
|
||||
"version": "0.18.52",
|
||||
"mac": {
|
||||
"file": "daemon/Arcrun-0.18.49.dmg",
|
||||
"sha256": "7ff5a6154314292e1e56137d106185515c01bab9762fbadf2ed03c5b07cd195c"
|
||||
"file": "daemon/Arcrun-0.18.52.dmg",
|
||||
"sha256": "a3bc29ef6dc00da982a2d7990fc1731d88fc1436df38d75499aafae0245a09ce"
|
||||
},
|
||||
"win": {
|
||||
"file": "daemon/Arcrun-win-0.18.49.exe",
|
||||
"sha256": "ec3da2ec1f6236c7c8937a1ea88e0dc728070819174304e1e7c0d3fb72d6169b"
|
||||
"file": "daemon/Arcrun-win-0.18.52.exe",
|
||||
"sha256": "9e074685eb44e6a4729c819411221cc24018f7cdfcb732df15911741a5356f80"
|
||||
},
|
||||
"msix": {
|
||||
"file": "daemon/Arcrun-0.18.49.msix",
|
||||
"sha256": "a24fc99db714f6f7eac8205b27381710d9814a9558b9734dd2cada7379903f56"
|
||||
"file": "daemon/Arcrun-0.18.52.msix",
|
||||
"sha256": "6e1f022ce812fb7c92ca7a4fbc32c428ab483a666971d96bfb5acdef4b0fdca3"
|
||||
},
|
||||
"built": "20260829-1134",
|
||||
"notes": "雲端的知識庫清單看得出每一個資料夾是從哪一台電腦同步上去的・改名之後不會多出一台電腦,也不必等下一次改檔才生效:名字一換,下一輪就會把新名字送上去(細節見說明文件)"
|
||||
"built": "20260913-2009",
|
||||
"notes": "雲端資料庫的免費額度用完時,小幫手會直接告訴你・額度用完期間不再重打雲端・急著用的話,卡片上也會告訴你:升級 Cloudflare Workers 付費方案(每月 5 美元起)就沒有每日上限"
|
||||
},
|
||||
"installer": {
|
||||
"version": "1.0.10"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user