feat(authz): add atomic mobile knowledge writes
This commit is contained in:
parent
a3282f7fe1
commit
5e2ba5b006
6 changed files with 527 additions and 19 deletions
|
|
@ -35,6 +35,10 @@ HoloLake 手机端使用独立的邮箱验证码会话,不复用工单批准
|
||||||
- 六位验证码只在邮件正文出现,服务器状态仅保存带私密 pepper 的摘要,十分钟失效且最多尝试五次;
|
- 六位验证码只在邮件正文出现,服务器状态仅保存带私密 pepper 的摘要,十分钟失效且最多尝试五次;
|
||||||
- 会话绑定 HoloLake 设备编号,手机仅保存短期会话令牌;服务器磁盘仍只保存令牌摘要;
|
- 会话绑定 HoloLake 设备编号,手机仅保存短期会话令牌;服务器磁盘仍只保存令牌摘要;
|
||||||
- `/api/hololake/knowledge/manifest` 与 `/archive` 只暴露固定登记仓库
|
- `/api/hololake/knowledge/manifest` 与 `/archive` 只暴露固定登记仓库
|
||||||
|
- `PUT /api/hololake/knowledge/page` 只接受普通 Markdown、精确 `main`
|
||||||
|
基线与已绑定设备会话;服务器用临时 Git index 原子前移裸仓库并返回提交回执
|
||||||
|
- `本地密钥/**`、路径穿越、非 Markdown、超限内容与过期基线全部失败关闭,回执
|
||||||
|
不包含页面内容
|
||||||
`bingshuo/hololake-knowledge-base` 的当前 `refs/heads/main` 快照,不向手机下发 Forgejo 凭据;
|
`bingshuo/hololake-knowledge-base` 的当前 `refs/heads/main` 快照,不向手机下发 Forgejo 凭据;
|
||||||
- `/api/hololake/ai/catalog` 只返回可用模型编号;`/execute` 只调用服务器登记的提供商与模型,
|
- `/api/hololake/ai/catalog` 只返回可用模型编号;`/execute` 只调用服务器登记的提供商与模型,
|
||||||
不接受任意 URL、请求头或密钥,响应和回执均不包含服务器密钥。
|
不接受任意 URL、请求头或密钥,响应和回执均不包含服务器密钥。
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,25 @@ async function withServer(run) {
|
||||||
content_type: "application/zip",
|
content_type: "application/zip",
|
||||||
body: Buffer.from("PK-test-archive"),
|
body: Buffer.from("PK-test-archive"),
|
||||||
}),
|
}),
|
||||||
|
writePage: request => {
|
||||||
|
if (request.path.includes("本地密钥")) {
|
||||||
|
throw new Error("knowledge_secret_page_forbidden");
|
||||||
|
}
|
||||||
|
if (request.path === "notes/conflict.md") {
|
||||||
|
throw new Error("knowledge_commit_conflict");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
schema: "guanghu.hololake-knowledge-write-receipt/v1",
|
||||||
|
repository: "bingshuo/hololake-knowledge-base",
|
||||||
|
path: request.path,
|
||||||
|
base_commit: request.baseCommit,
|
||||||
|
commit: "c".repeat(40),
|
||||||
|
content_sha256: crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update(request.content)
|
||||||
|
.digest("hex"),
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const aiGateway = {
|
const aiGateway = {
|
||||||
catalog: () => ({
|
catalog: () => ({
|
||||||
|
|
@ -177,6 +196,21 @@ test("authenticated device can read fixed knowledge, use AI proxy, inspect and r
|
||||||
assert.equal(archive.headers.get("x-content-sha256"), "b".repeat(64));
|
assert.equal(archive.headers.get("x-content-sha256"), "b".repeat(64));
|
||||||
assert.equal(Buffer.from(await archive.arrayBuffer()).toString(), "PK-test-archive");
|
assert.equal(Buffer.from(await archive.arrayBuffer()).toString(), "PK-test-archive");
|
||||||
|
|
||||||
|
const pageWrite = await fetch(`${base}/api/hololake/knowledge/page`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { ...headers, "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
path: "个人知识/手机页面.md",
|
||||||
|
content: "# 手机页面\n",
|
||||||
|
base_commit: "a".repeat(40),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(pageWrite.status, 200);
|
||||||
|
const writeReceipt = await pageWrite.json();
|
||||||
|
assert.equal(writeReceipt.path, "个人知识/手机页面.md");
|
||||||
|
assert.equal(writeReceipt.commit, "c".repeat(40));
|
||||||
|
assert.doesNotMatch(JSON.stringify(writeReceipt), /# 手机页面/);
|
||||||
|
|
||||||
const ai = await fetch(`${base}/api/hololake/ai/execute`, {
|
const ai = await fetch(`${base}/api/hololake/ai/execute`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...headers, "content-type": "application/json" },
|
headers: { ...headers, "content-type": "application/json" },
|
||||||
|
|
@ -221,6 +255,18 @@ test("knowledge and AI endpoints require the session and matching device", async
|
||||||
})).status,
|
})).status,
|
||||||
401,
|
401,
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await fetch(`${base}/api/hololake/knowledge/page`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
path: "notes/no-session.md",
|
||||||
|
content: "# no\n",
|
||||||
|
base_commit: "a".repeat(40),
|
||||||
|
}),
|
||||||
|
})).status,
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
|
||||||
const token = await login(base, mail);
|
const token = await login(base, mail);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|
@ -234,3 +280,39 @@ test("knowledge and AI endpoints require the session and matching device", async
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("knowledge write endpoint maps conflicts and validation failures without echoing content", async () => {
|
||||||
|
await withServer(async ({ base, mail }) => {
|
||||||
|
const deviceId = "ios-device-001";
|
||||||
|
const token = await login(base, mail, deviceId);
|
||||||
|
const headers = {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
"x-hololake-device-id": deviceId,
|
||||||
|
"content-type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
const forbidden = await fetch(`${base}/api/hololake/knowledge/page`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
path: "本地密钥/openai.md",
|
||||||
|
content: "super-secret-value",
|
||||||
|
base_commit: "a".repeat(40),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(forbidden.status, 400);
|
||||||
|
assert.doesNotMatch(await forbidden.text(), /super-secret-value/);
|
||||||
|
|
||||||
|
const conflict = await fetch(`${base}/api/hololake/knowledge/page`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
path: "notes/conflict.md",
|
||||||
|
content: "# stale\n",
|
||||||
|
base_commit: "a".repeat(40),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(conflict.status, 409);
|
||||||
|
assert.equal((await conflict.json()).error, "knowledge_commit_conflict");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@
|
||||||
const childProcess = require("node:child_process");
|
const childProcess = require("node:child_process");
|
||||||
const crypto = require("node:crypto");
|
const crypto = require("node:crypto");
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const MAX_KNOWLEDGE_PAGE_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
class HoloLakeKnowledgeProvider {
|
class HoloLakeKnowledgeProvider {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
|
|
@ -63,14 +67,104 @@ class HoloLakeKnowledgeProvider {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
git(args, maxBuffer = 1024 * 1024) {
|
writePage(request = {}) {
|
||||||
|
const pagePath = validateKnowledgePagePath(request.path);
|
||||||
|
if (typeof request.content !== "string") {
|
||||||
|
throw new Error("knowledge_page_content_invalid");
|
||||||
|
}
|
||||||
|
const content = Buffer.from(request.content, "utf8");
|
||||||
|
if (content.length > MAX_KNOWLEDGE_PAGE_BYTES) {
|
||||||
|
throw new Error("knowledge_page_too_large");
|
||||||
|
}
|
||||||
|
if (content.includes(0)) {
|
||||||
|
throw new Error("knowledge_page_content_invalid");
|
||||||
|
}
|
||||||
|
const current = this.manifest().commit;
|
||||||
|
const baseCommit = String(request.baseCommit || "");
|
||||||
|
if (!/^[0-9a-f]{40}$/.test(baseCommit) || !safeEqual(baseCommit, current)) {
|
||||||
|
throw new Error("knowledge_commit_conflict");
|
||||||
|
}
|
||||||
|
|
||||||
|
const temporary = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), "hololake-knowledge-index-"),
|
||||||
|
);
|
||||||
|
const indexFile = path.join(temporary, "index");
|
||||||
|
const gitEnv = {
|
||||||
|
...process.env,
|
||||||
|
GIT_INDEX_FILE: indexFile,
|
||||||
|
GIT_AUTHOR_NAME: "HoloLake Knowledge",
|
||||||
|
GIT_AUTHOR_EMAIL: "knowledge@guanghulake.invalid",
|
||||||
|
GIT_COMMITTER_NAME: "HoloLake Knowledge",
|
||||||
|
GIT_COMMITTER_EMAIL: "knowledge@guanghulake.invalid",
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
this.git(["read-tree", current], 1024 * 1024, { env: gitEnv });
|
||||||
|
const blob = this.git(
|
||||||
|
["hash-object", "-w", "--stdin"],
|
||||||
|
MAX_KNOWLEDGE_PAGE_BYTES + 1024,
|
||||||
|
{ env: gitEnv, input: content },
|
||||||
|
).toString("utf8").trim();
|
||||||
|
if (!/^[0-9a-f]{40}$/.test(blob)) {
|
||||||
|
throw new Error("knowledge_blob_invalid");
|
||||||
|
}
|
||||||
|
this.git(
|
||||||
|
["update-index", "--add", "--cacheinfo", "100644", blob, pagePath],
|
||||||
|
1024 * 1024,
|
||||||
|
{ env: gitEnv },
|
||||||
|
);
|
||||||
|
const tree = this.git(["write-tree"], 1024 * 1024, { env: gitEnv })
|
||||||
|
.toString("utf8")
|
||||||
|
.trim();
|
||||||
|
if (!/^[0-9a-f]{40}$/.test(tree)) {
|
||||||
|
throw new Error("knowledge_tree_invalid");
|
||||||
|
}
|
||||||
|
const nextCommit = this.git(
|
||||||
|
[
|
||||||
|
"commit-tree",
|
||||||
|
tree,
|
||||||
|
"-p",
|
||||||
|
current,
|
||||||
|
"-m",
|
||||||
|
`HoloLake knowledge: ${pagePath}`,
|
||||||
|
],
|
||||||
|
1024 * 1024,
|
||||||
|
{ env: gitEnv },
|
||||||
|
).toString("utf8").trim();
|
||||||
|
if (!/^[0-9a-f]{40}$/.test(nextCommit)) {
|
||||||
|
throw new Error("knowledge_commit_invalid");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.git(
|
||||||
|
["update-ref", this.ref, nextCommit, current],
|
||||||
|
1024 * 1024,
|
||||||
|
{ env: gitEnv },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
throw new Error("knowledge_commit_conflict");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
schema: "guanghu.hololake-knowledge-write-receipt/v1",
|
||||||
|
repository: this.repositoryId,
|
||||||
|
path: pagePath,
|
||||||
|
base_commit: current,
|
||||||
|
commit: nextCommit,
|
||||||
|
content_sha256: crypto.createHash("sha256").update(content).digest("hex"),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(temporary, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
git(args, maxBuffer = 1024 * 1024, options = {}) {
|
||||||
return childProcess.execFileSync(
|
return childProcess.execFileSync(
|
||||||
"git",
|
"git",
|
||||||
[`--git-dir=${this.repositoryPath}`, ...args],
|
[`--git-dir=${this.repositoryPath}`, ...args],
|
||||||
{
|
{
|
||||||
encoding: "buffer",
|
encoding: "buffer",
|
||||||
maxBuffer,
|
maxBuffer,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
||||||
|
env: options.env || process.env,
|
||||||
|
input: options.input,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -119,6 +213,7 @@ class HoloLakeAiGateway {
|
||||||
this.maxMessages,
|
this.maxMessages,
|
||||||
this.maxInputCharacters,
|
this.maxInputCharacters,
|
||||||
);
|
);
|
||||||
|
const tools = validateTools(request.tools);
|
||||||
const maxTokens = Number(request.max_tokens || 1024);
|
const maxTokens = Number(request.max_tokens || 1024);
|
||||||
if (
|
if (
|
||||||
!Number.isInteger(maxTokens)
|
!Number.isInteger(maxTokens)
|
||||||
|
|
@ -128,18 +223,23 @@ class HoloLakeAiGateway {
|
||||||
throw new Error("ai_output_limit_exceeded");
|
throw new Error("ai_output_limit_exceeded");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const upstreamBody = {
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
stream: false,
|
||||||
|
};
|
||||||
|
if (tools.length > 0) {
|
||||||
|
upstreamBody.tools = tools;
|
||||||
|
upstreamBody.tool_choice = request.tool_choice === "none" ? "none" : "auto";
|
||||||
|
}
|
||||||
const response = await this.fetchImpl(`${provider.baseUrl}/chat/completions`, {
|
const response = await this.fetchImpl(`${provider.baseUrl}/chat/completions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `Bearer ${provider.apiKey}`,
|
authorization: `Bearer ${provider.apiKey}`,
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(upstreamBody),
|
||||||
model,
|
|
||||||
messages,
|
|
||||||
max_tokens: maxTokens,
|
|
||||||
stream: false,
|
|
||||||
}),
|
|
||||||
signal: AbortSignal.timeout(provider.timeoutMs),
|
signal: AbortSignal.timeout(provider.timeoutMs),
|
||||||
});
|
});
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
|
@ -215,26 +315,144 @@ function validateMessages(messages, maxMessages, maxInputCharacters) {
|
||||||
if (messages.length > maxMessages) throw new Error("ai_input_too_large");
|
if (messages.length > maxMessages) throw new Error("ai_input_too_large");
|
||||||
let characters = 0;
|
let characters = 0;
|
||||||
const normalized = messages.map(message => {
|
const normalized = messages.map(message => {
|
||||||
|
if (!message || !["system", "user", "assistant", "tool"].includes(message.role)) {
|
||||||
|
throw new Error("ai_message_invalid");
|
||||||
|
}
|
||||||
|
if (message.role === "tool") {
|
||||||
if (
|
if (
|
||||||
!message
|
typeof message.content !== "string"
|
||||||
|| !["system", "user", "assistant"].includes(message.role)
|
|| !/^[A-Za-z0-9._:-]{1,160}$/.test(String(message.tool_call_id || ""))
|
||||||
|| typeof message.content !== "string"
|
|
||||||
) {
|
) {
|
||||||
throw new Error("ai_message_invalid");
|
throw new Error("ai_message_invalid");
|
||||||
}
|
}
|
||||||
characters += message.content.length;
|
characters += message.content.length;
|
||||||
return { role: message.role, content: message.content };
|
return {
|
||||||
|
role: "tool",
|
||||||
|
tool_call_id: message.tool_call_id,
|
||||||
|
content: message.content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const content = message.content === null && message.role === "assistant"
|
||||||
|
? null
|
||||||
|
: message.content;
|
||||||
|
if (typeof content !== "string" && content !== null) {
|
||||||
|
throw new Error("ai_message_invalid");
|
||||||
|
}
|
||||||
|
characters += typeof content === "string" ? content.length : 0;
|
||||||
|
const normalizedMessage = { role: message.role, content };
|
||||||
|
if (message.role === "assistant" && message.tool_calls !== undefined) {
|
||||||
|
normalizedMessage.tool_calls = validateToolCalls(message.tool_calls);
|
||||||
|
characters += normalizedMessage.tool_calls.reduce(
|
||||||
|
(total, call) => total + call.function.arguments.length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalizedMessage;
|
||||||
});
|
});
|
||||||
if (characters > maxInputCharacters) throw new Error("ai_input_too_large");
|
if (characters > maxInputCharacters) throw new Error("ai_input_too_large");
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateToolCalls(value) {
|
||||||
|
if (!Array.isArray(value) || value.length < 1 || value.length > 32) {
|
||||||
|
throw new Error("ai_message_invalid");
|
||||||
|
}
|
||||||
|
return value.map(call => {
|
||||||
|
if (
|
||||||
|
!call
|
||||||
|
|| call.type !== "function"
|
||||||
|
|| !/^[A-Za-z0-9._:-]{1,160}$/.test(String(call.id || ""))
|
||||||
|
|| !call.function
|
||||||
|
|| !/^[A-Za-z0-9_-]{1,80}$/.test(String(call.function.name || ""))
|
||||||
|
|| typeof call.function.arguments !== "string"
|
||||||
|
|| call.function.arguments.length > 32 * 1024
|
||||||
|
) {
|
||||||
|
throw new Error("ai_message_invalid");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: call.id,
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: call.function.name,
|
||||||
|
arguments: call.function.arguments,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTools(value) {
|
||||||
|
if (value === undefined || value === null) return [];
|
||||||
|
if (!Array.isArray(value) || value.length > 32) {
|
||||||
|
throw new Error("ai_tools_invalid");
|
||||||
|
}
|
||||||
|
const encoded = JSON.stringify(value);
|
||||||
|
if (Buffer.byteLength(encoded) > 128 * 1024) {
|
||||||
|
throw new Error("ai_tools_invalid");
|
||||||
|
}
|
||||||
|
return value.map(tool => {
|
||||||
|
if (
|
||||||
|
!tool
|
||||||
|
|| tool.type !== "function"
|
||||||
|
|| !tool.function
|
||||||
|
|| !/^[A-Za-z0-9_-]{1,80}$/.test(String(tool.function.name || ""))
|
||||||
|
|| typeof tool.function.description !== "string"
|
||||||
|
|| tool.function.description.length > 4_000
|
||||||
|
|| !tool.function.parameters
|
||||||
|
|| typeof tool.function.parameters !== "object"
|
||||||
|
|| Array.isArray(tool.function.parameters)
|
||||||
|
) {
|
||||||
|
throw new Error("ai_tools_invalid");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: tool.function.name,
|
||||||
|
description: tool.function.description,
|
||||||
|
parameters: JSON.parse(JSON.stringify(tool.function.parameters)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function safeEqual(left, right) {
|
function safeEqual(left, right) {
|
||||||
const a = Buffer.from(String(left));
|
const a = Buffer.from(String(left));
|
||||||
const b = Buffer.from(String(right));
|
const b = Buffer.from(String(right));
|
||||||
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateKnowledgePagePath(value) {
|
||||||
|
const pagePath = String(value || "").normalize("NFC");
|
||||||
|
if (
|
||||||
|
pagePath.length < 1
|
||||||
|
|| pagePath.length > 512
|
||||||
|
|| pagePath.startsWith("/")
|
||||||
|
|| pagePath.endsWith("/")
|
||||||
|
|| pagePath.includes("\\")
|
||||||
|
|| /[\0-\x1f\x7f]/u.test(pagePath)
|
||||||
|
|| !pagePath.endsWith(".md")
|
||||||
|
) {
|
||||||
|
throw new Error("knowledge_page_path_invalid");
|
||||||
|
}
|
||||||
|
const components = pagePath.split("/");
|
||||||
|
if (
|
||||||
|
components.length < 1
|
||||||
|
|| components.length > 16
|
||||||
|
|| components.some(component => (
|
||||||
|
!component
|
||||||
|
|| component === "."
|
||||||
|
|| component === ".."
|
||||||
|
|| component.length > 120
|
||||||
|
|| component.toLocaleLowerCase("en-US") === ".git"
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
throw new Error("knowledge_page_path_invalid");
|
||||||
|
}
|
||||||
|
if (components.some(component => component.normalize("NFKC") === "本地密钥")) {
|
||||||
|
throw new Error("knowledge_secret_page_forbidden");
|
||||||
|
}
|
||||||
|
return pagePath;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
HoloLakeAiGateway,
|
HoloLakeAiGateway,
|
||||||
HoloLakeKnowledgeProvider,
|
HoloLakeKnowledgeProvider,
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,93 @@ test("knowledge provider exposes only the fixed main commit and archive", () =>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("knowledge provider atomically writes Markdown and returns a verifiable commit receipt", () => {
|
||||||
|
const repository = createBareKnowledgeRepository();
|
||||||
|
const provider = new HoloLakeKnowledgeProvider({
|
||||||
|
repositoryId: "bingshuo/hololake-knowledge-base",
|
||||||
|
repositoryPath: repository.bare,
|
||||||
|
});
|
||||||
|
const base = provider.manifest().commit;
|
||||||
|
const content = "# 手机知识页\n\n由 HoloLake Agent 写入。\n";
|
||||||
|
|
||||||
|
const receipt = provider.writePage({
|
||||||
|
path: "个人知识/手机知识页.md",
|
||||||
|
content,
|
||||||
|
baseCommit: base,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(receipt.schema, "guanghu.hololake-knowledge-write-receipt/v1");
|
||||||
|
assert.equal(receipt.repository, "bingshuo/hololake-knowledge-base");
|
||||||
|
assert.equal(receipt.path, "个人知识/手机知识页.md");
|
||||||
|
assert.equal(receipt.base_commit, base);
|
||||||
|
assert.match(receipt.commit, /^[0-9a-f]{40}$/);
|
||||||
|
assert.notEqual(receipt.commit, base);
|
||||||
|
assert.equal(
|
||||||
|
receipt.content_sha256,
|
||||||
|
require("node:crypto").createHash("sha256").update(content).digest("hex"),
|
||||||
|
);
|
||||||
|
assert.equal(provider.manifest().commit, receipt.commit);
|
||||||
|
assert.equal(
|
||||||
|
childProcess.execFileSync(
|
||||||
|
"git",
|
||||||
|
[`--git-dir=${repository.bare}`, "show", `${receipt.commit}:个人知识/手机知识页.md`],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
),
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(JSON.stringify(receipt), /由 HoloLake Agent 写入/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("knowledge provider rejects secret pages, unsafe paths, oversized content, and stale commits", () => {
|
||||||
|
const repository = createBareKnowledgeRepository();
|
||||||
|
const provider = new HoloLakeKnowledgeProvider({
|
||||||
|
repositoryId: "bingshuo/hololake-knowledge-base",
|
||||||
|
repositoryPath: repository.bare,
|
||||||
|
});
|
||||||
|
const base = provider.manifest().commit;
|
||||||
|
|
||||||
|
for (const invalidPath of [
|
||||||
|
"本地密钥/openai.md",
|
||||||
|
"../outside.md",
|
||||||
|
"notes/plain.txt",
|
||||||
|
"/absolute.md",
|
||||||
|
"notes//empty.md",
|
||||||
|
]) {
|
||||||
|
assert.throws(
|
||||||
|
() => provider.writePage({
|
||||||
|
path: invalidPath,
|
||||||
|
content: "# blocked\n",
|
||||||
|
baseCommit: base,
|
||||||
|
}),
|
||||||
|
/knowledge_page_path_invalid|knowledge_secret_page_forbidden/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.throws(
|
||||||
|
() => provider.writePage({
|
||||||
|
path: "notes/large.md",
|
||||||
|
content: "x".repeat(1024 * 1024 + 1),
|
||||||
|
baseCommit: base,
|
||||||
|
}),
|
||||||
|
/knowledge_page_too_large/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = provider.writePage({
|
||||||
|
path: "notes/first.md",
|
||||||
|
content: "# first\n",
|
||||||
|
baseCommit: base,
|
||||||
|
});
|
||||||
|
assert.match(first.commit, /^[0-9a-f]{40}$/);
|
||||||
|
assert.throws(
|
||||||
|
() => provider.writePage({
|
||||||
|
path: "notes/stale.md",
|
||||||
|
content: "# stale\n",
|
||||||
|
baseCommit: base,
|
||||||
|
}),
|
||||||
|
/knowledge_commit_conflict/,
|
||||||
|
);
|
||||||
|
assert.equal(provider.manifest().commit, first.commit);
|
||||||
|
});
|
||||||
|
|
||||||
test("AI gateway accepts only registered provider models and never leaks the key", async () => {
|
test("AI gateway accepts only registered provider models and never leaks the key", async () => {
|
||||||
const requests = [];
|
const requests = [];
|
||||||
const gateway = new HoloLakeAiGateway({
|
const gateway = new HoloLakeAiGateway({
|
||||||
|
|
@ -115,6 +202,69 @@ test("AI gateway accepts only registered provider models and never leaks the key
|
||||||
assert.equal(requests.length, 1);
|
assert.equal(requests.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("AI gateway forwards bounded tool contracts and tool receipts without exposing server secrets", async () => {
|
||||||
|
const requests = [];
|
||||||
|
const gateway = new HoloLakeAiGateway({
|
||||||
|
providers: {
|
||||||
|
default: {
|
||||||
|
baseUrl: "https://models.example.invalid/v1",
|
||||||
|
apiKey: "server-secret-key",
|
||||||
|
models: ["gpt-test"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fetchImpl: async (_url, options) => {
|
||||||
|
requests.push(JSON.parse(options.body));
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
choices: [{ message: { role: "assistant", content: "saved" } }],
|
||||||
|
}), { status: 200 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await gateway.execute({
|
||||||
|
provider: "default",
|
||||||
|
model: "gpt-test",
|
||||||
|
messages: [
|
||||||
|
{ role: "user", content: "save this" },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: null,
|
||||||
|
tool_calls: [{
|
||||||
|
id: "call-1",
|
||||||
|
type: "function",
|
||||||
|
function: { name: "create_note", arguments: "{\"path\":\"notes/a.md\"}" },
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{ role: "tool", tool_call_id: "call-1", content: "commit=abc" },
|
||||||
|
],
|
||||||
|
tools: [{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "create_note",
|
||||||
|
description: "Create one Markdown page.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: { path: { type: "string" } },
|
||||||
|
required: ["path"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
tool_choice: "auto",
|
||||||
|
});
|
||||||
|
assert.equal(result.response.choices[0].message.content, "saved");
|
||||||
|
assert.equal(requests[0].tools[0].function.name, "create_note");
|
||||||
|
assert.equal(requests[0].messages[2].role, "tool");
|
||||||
|
assert.equal(requests[0].tool_choice, "auto");
|
||||||
|
assert.doesNotMatch(JSON.stringify(result), /server-secret-key/);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => gateway.execute({
|
||||||
|
provider: "default",
|
||||||
|
model: "gpt-test",
|
||||||
|
messages: [{ role: "tool", tool_call_id: "../bad", content: "x" }],
|
||||||
|
}),
|
||||||
|
/ai_message_invalid/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("AI gateway bounds message shape, count, text, and requested output", async () => {
|
test("AI gateway bounds message shape, count, text, and requested output", async () => {
|
||||||
const gateway = new HoloLakeAiGateway({
|
const gateway = new HoloLakeAiGateway({
|
||||||
providers: {
|
providers: {
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ NoNewPrivileges=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=true
|
ProtectHome=true
|
||||||
ReadWritePaths=/var/lib/guanghu/lake-lamp-authz /var/lib/guanghu/repo-authorizations /var/lib/guanghu/repo-push-uploads /var/lib/guanghu/forgejo/repositories/bingshuo/hololake-platform.git /var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories/bingshuo/guanghu-ice-heart.git
|
ReadWritePaths=/var/lib/guanghu/lake-lamp-authz /var/lib/guanghu/repo-authorizations /var/lib/guanghu/repo-push-uploads /var/lib/guanghu/forgejo/repositories/bingshuo/hololake-platform.git /var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories/bingshuo/guanghu-ice-heart.git -/var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories/bingshuo/hololake-knowledge-base.git
|
||||||
ReadOnlyPaths=-/var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories/bingshuo/hololake-knowledge-base.git -/etc/guanghu/secrets/hololake-ai-providers.json
|
ReadOnlyPaths=-/etc/guanghu/secrets/hololake-ai-providers.json
|
||||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
LockPersonality=true
|
LockPersonality=true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -437,6 +437,49 @@ function createApp(options = {}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
req.method === "PUT"
|
||||||
|
&& url.pathname === "/api/hololake/knowledge/page"
|
||||||
|
) {
|
||||||
|
const authenticated = authenticateHoloLake(req);
|
||||||
|
if (!authenticated.ok) {
|
||||||
|
return json(
|
||||||
|
res,
|
||||||
|
authenticated.status,
|
||||||
|
failure(authenticated.error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hololakeKnowledgeProvider) {
|
||||||
|
return json(res, 503, failure("knowledge_repository_unavailable"));
|
||||||
|
}
|
||||||
|
const body = await readJson(req, 1024 * 1024 + 16 * 1024);
|
||||||
|
if (!body || typeof body !== "object") {
|
||||||
|
return json(res, 400, failure("knowledge_page_request_invalid"));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const receipt = hololakeKnowledgeProvider.writePage({
|
||||||
|
path: body.path,
|
||||||
|
content: body.content,
|
||||||
|
baseCommit: body.base_commit,
|
||||||
|
});
|
||||||
|
return json(res, 200, receipt);
|
||||||
|
} catch (error) {
|
||||||
|
const code = String(error && error.message || "");
|
||||||
|
if (code === "knowledge_commit_conflict") {
|
||||||
|
return json(res, 409, failure(code));
|
||||||
|
}
|
||||||
|
if ([
|
||||||
|
"knowledge_page_path_invalid",
|
||||||
|
"knowledge_secret_page_forbidden",
|
||||||
|
"knowledge_page_content_invalid",
|
||||||
|
"knowledge_page_too_large",
|
||||||
|
].includes(code)) {
|
||||||
|
return json(res, 400, failure(code));
|
||||||
|
}
|
||||||
|
return json(res, 503, failure("knowledge_repository_unavailable"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
req.method === "GET"
|
req.method === "GET"
|
||||||
&& url.pathname === "/api/hololake/knowledge/archive"
|
&& url.pathname === "/api/hololake/knowledge/archive"
|
||||||
|
|
@ -1252,10 +1295,21 @@ function document(title, body) {
|
||||||
</style></head><body><main class="shell">${body}</main></body></html>`;
|
</style></head><body><main class="shell">${body}</main></body></html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readJson(req) {
|
function readJson(req, maxBytes = 32 * 1024) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let raw = "";
|
let raw = "";
|
||||||
req.on("data", chunk => { raw += chunk; if (raw.length > 32 * 1024) { const error = new Error("body too large"); error.code = "BODY_TOO_LARGE"; reject(error); req.destroy(); } });
|
let received = 0;
|
||||||
|
req.on("data", chunk => {
|
||||||
|
received += chunk.length;
|
||||||
|
if (received > maxBytes) {
|
||||||
|
const error = new Error("body too large");
|
||||||
|
error.code = "BODY_TOO_LARGE";
|
||||||
|
reject(error);
|
||||||
|
req.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
raw += chunk;
|
||||||
|
});
|
||||||
req.on("end", () => { try { resolve(JSON.parse(raw || "{}")); } catch { resolve(null); } });
|
req.on("end", () => { try { resolve(JSON.parse(raw || "{}")); } catch { resolve(null); } });
|
||||||
req.on("error", reject);
|
req.on("error", reject);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue