Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ permissions:

jobs:
test:
runs-on: ubuntu-latest
name: Node 20 / ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
Expand Down
14 changes: 13 additions & 1 deletion demohouse/sales-intelligence-workbench/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
# 变更记录

本文件记录各发行版本的公开变化。当前版本为 `0.10.0` 自托管开源版。
本文件记录各发行版本的公开变化。当前版本为 `0.10.1` 自托管开源版。

## 0.10.1 - 2026-09-03

- 将火山方舟 AI App Lab 中的 Skill 设为统一公开入口,同时把真实安装包固定到独立发行仓库的版本化 tag。
- 远程初始化不再根据入口 URL 猜测下载仓库或项目根目录,避免从 monorepo 根目录执行销售工作台安装命令。
- 增加官方入口、固定发行源和双端隔离安装校验;下载、校验和安装阶段仍不调用外部 Provider 或产生 AFP。
- 增加固定仓库、本地与远程 tag/commit、ignored 残留和干净工作区校验器;远程入口只允许全新临时 clone,不复用已有目录。
- 发布后验收会确认 AI App Lab 固定 commit 已进入 `main`,并比较官方完整项目镜像,而非只比较入口文件。
- 独立发行要求 GitHub immutable Release,阻止 `v0.10.1` tag 或发行资产在宣传后被替换。
- Node 20 发布 CI 扩展为 Ubuntu、macOS 和 Windows 三平台矩阵,任一平台失败都不进入发布。
- 公开口令生成器拒绝浮动 ref、错误仓库、错误版本和错误 Skill 路径;重大宣传支持固定到 AI App Lab 合并 commit。
- 运行时安装元数据记录发行版本和应用包内容哈希;安装测试后再次核对,后续 status 与业务脚本持续检查实际运行包未被替换或篡改。

## 0.10.0 - 2026-08-03

Expand Down
60 changes: 49 additions & 11 deletions demohouse/sales-intelligence-workbench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

销售智能工作台把企业专业数据、公开信息、飞书资料和长期记忆组织成可追溯的企业档案与资料问答。项目包含完整前后端、AI Native 应用开发底座(Supabase)数据层、Agent 记忆(OpenViking)链路,以及可在 Codex 和 Claude Code 中安装并运维应用的同一份 Skill。

> 当前为 `0.10.0` 自托管开源版,支持单工作区、单管理员,以及本机或受控内网部署。已支持 Supabase Auth、工作区数据隔离、安全请求边界、工作区级付费任务保护、持久化异步任务队列和独立 Worker。当前版本不提供公网托管 SaaS、多人协作或 SLA;不要把 Node.js 服务端口直接暴露到公网。
> 当前为 `0.10.1` 自托管开源版,支持单工作区、单管理员,以及本机或受控内网部署。已支持 Supabase Auth、工作区数据隔离、安全请求边界、工作区级付费任务保护、持久化异步任务队列和独立 Worker。当前版本不提供公网托管 SaaS、多人协作或 SLA;不要把 Node.js 服务端口直接暴露到公网。

## 核心能力

Expand Down Expand Up @@ -52,23 +52,59 @@ AI Native 应用开发底座(Supabase)是结构化业务事实库;Agent

### 面向最终用户:一句话初始化

当前独立发行仓库的版本化初始化入口为
火山方舟 AI App Lab 中的官方初始化入口为

> 帮我初始化销售助手:`https://github.com/3494036618-eng/sales-intelligence-workbench/blob/v0.10.0/skills/sales-intelligence-workbench/SKILL.md`
> 帮我初始化销售助手:`https://github.com/volcengine/ai-app-lab/blob/main/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/SKILL.md`

该 URL 直接指向唯一的正式 Skill。即使用户本机没有仓库、依赖和配置文件,当前 Agent 也会
先解释下载与本机写入影响,再从 URL 指定的版本取得完整仓库,执行离线校验,并把同一份
Skill 安装到当前客户端后立即衔接下面的 Cookbook 搭建流程。读取 URL、下载仓库和离线校验
不会创建云资源或产生 AFP。
上面的 `main` 地址用于日常获取官方最新版本。重大宣传必须在 AI App Lab PR 合并后,把
`main` 替换为该次合并的完整 40 位 commit SHA,确保活动期间入口内容不会漂移。

初始化入口必须固定到已发布的 tag 或经过审核的 commit SHA。也可以用仓库脚本生成口令:
该 URL 只负责提供官方入口。Skill 会固定从独立发行仓库的 `v0.10.1` tag 获取完整安装包,
不会把 AI App Lab 的 `main` 分支当作应用发行版本,也不会下载整个 monorepo 后猜测项目目录。
即使用户本机没有仓库、依赖和配置文件,当前 Agent 也会先解释下载与本机写入影响,再取得
经过验证的固定版本,执行离线校验,并把同一份 Skill 安装到当前客户端后立即衔接下面的
Cookbook 搭建流程。读取入口、下载仓库和离线校验不会创建云资源或产生 AFP。

固定发行源为:

```text
固定发行仓库:https://github.com/3494036618-eng/sales-intelligence-workbench/tree/v0.10.1
固定发行 Skill:https://github.com/3494036618-eng/sales-intelligence-workbench/blob/v0.10.1/skills/sales-intelligence-workbench/SKILL.md
```

维护者可以用仓库脚本生成官方口令:

```bash
npm run skill:command -- --official
npm run skill:command -- --official-ref <AI App Lab完整40位commit SHA>
```

`--official` 只生成日常最新入口;第二条命令生成重大宣传使用的固定入口。生成器默认只允许
当前独立发行仓库、当前版本和 AI App Lab 正式路径,`HEAD`、分支名、错误仓库、错误版本和
错误 Skill 路径都会被拒绝。

维护者发布 `v0.10.1` tag 和正式 GitHub Release 后,必须开启 Release 的 immutable 属性;
发布前分支的 Node 20 CI 必须在 Ubuntu、macOS 和 Windows 三个平台全部通过。提交
AI App Lab PR 前,再从 GitHub 全新克隆并验收真实发行包:

```bash
npm run release:verify:public
```

AI App Lab PR 合并后,再验证官方固定 commit 已进入 `main`,并核对完整项目镜像与
`UPSTREAM.json`:

```bash
npm run skill:command -- \
--repository https://github.com/3494036618-eng/sales-intelligence-workbench \
--ref v0.10.0
npm run release:verify:public -- --official-ref <AI App Lab完整40位commit SHA>
```

两次命令都通过之前不得对外宣传。它们会访问公开 GitHub、使用隔离临时目录运行完整
`npm run verify`,由维护者发布门校验 immutable Release,再校验远程 tag、commit、官方完整目录(含 Git mode)
和安装包身份后自动清理;不会调用
Agent Plan、创建云资源或产生 AFP。
immutable 状态只在发布门集中验证,最终用户安装时不额外调用 GitHub Release API,避免大型
活动共用出口触发匿名 API 限流;用户下载的 tag 已由通过门禁的 immutable Release 锁定。

这里的“从 0 搭建”不等于绕过第三方服务授权:用户仍需拥有 Agent Plan,并在控制台开启
专业数据集(DataPro)、豆包搜索(联网搜索)、Agent 记忆(OpenViking)和
AI Native 应用开发底座(Supabase),再按需完成飞书资料授权。用户侧只输入一枚
Expand Down Expand Up @@ -249,6 +285,8 @@ node skills/sales-intelligence-workbench/scripts/import-feishu.mjs \
--doc "https://example.feishu.cn/wiki/..."
```

> 文档中的 `0600`/`0700` 是 macOS/Linux 权限表达。Windows 下文件保存在当前用户的配置或状态目录,继承该用户目录的 NTFS ACL;运行时同时拒绝符号链接和非普通文件。

会话导入支持 `--p2p-user <联系人姓名>` 或 `--chat-id <oc_会话ID>`;云文档只接受完整链接。启用 `FEISHU_CLI_IMPORT_ENABLED=true` 后,登录用户也可以在“历史资料”模块点击“导入飞书资料”,选择会话或云文档并查看本机任务进度。两种入口调用同一条受控链路:正文只写入 Agent 记忆(OpenViking),AI Native 应用开发底座(Supabase)只保存来源、内容指纹、增量游标和 OpenViking 引用。详见 [飞书导入说明](skills/sales-intelligence-workbench/references/feishu-import.md)。

## 运维
Expand Down
2 changes: 1 addition & 1 deletion demohouse/sales-intelligence-workbench/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Supported scope

The current `v0.10.0` self-hosted open-source release supports one workspace and one local administrator. It includes Supabase Auth, browser and CLI sessions, CSRF protection for cookie-authenticated mutations, and an internal account binding for workspace isolation. It does not expose public registration, email confirmation, password-recovery email, member, or role-management flows.
The current `v0.10.1` self-hosted open-source release supports one workspace and one local administrator. It includes Supabase Auth, browser and CLI sessions, CSRF protection for cookie-authenticated mutations, and an internal account binding for workspace isolation. It does not expose public registration, email confirmation, password-recovery email, member, or role-management flows.

This is not a managed multi-tenant SaaS release. The default listener remains `127.0.0.1`. Do not expose the backend port directly to the public Internet; place the application behind an HTTPS reverse proxy and complete the deployment checks below.

Expand Down
8 changes: 4 additions & 4 deletions demohouse/sales-intelligence-workbench/UPSTREAM.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"repository": "https://github.com/3494036618-eng/sales-intelligence-workbench/",
"commit": "c9e3fe70824eae62d810d1d02186f616c4d2a041",
"version": "0.10.0",
"synced_at": "2026-08-21"
"repository": "https://github.com/3494036618-eng/sales-intelligence-workbench",
"commit": "fcd9907f89a8cc21d360384c40d19c4be2c6c45d",
"version": "0.10.1",
"synced_at": "2026-09-03"
}
4 changes: 2 additions & 2 deletions demohouse/sales-intelligence-workbench/backend/package.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"name": "sales-intelligence-workbench-api",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"type": "module",
"scripts": {
"dev": "node src/server.js",
"test": "node --test tests/*.test.mjs",
"test": "node scripts/run-tests.mjs",
"release:secrets": "node scripts/check-release-secrets.mjs",
"release:verify": "node scripts/verify-release-local.mjs",
"doctor": "node scripts/doctor.mjs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,13 @@ function parseArgs(argv) {
function assertPrivateSession(filePath) {
const session = readAuthSession(filePath);
if (!session) throw new Error("未找到 CLI 登录态。请先运行 Skill 的 login.mjs。");
const mode = fs.statSync(filePath).mode & 0o077;
if (mode !== 0) throw new Error("CLI 会话文件权限不安全;请将其权限改为 0600 后重试。");
const status = fs.lstatSync(filePath);
if (!status.isFile() || status.isSymbolicLink()) {
throw new Error("CLI 会话路径必须是非符号链接的普通文件。");
}
if (process.platform !== "win32" && (status.mode & 0o077) !== 0) {
throw new Error("CLI 会话文件权限不安全;请将其权限改为 0600 后重试。");
}
return session;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";

const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const backendDir = path.resolve(scriptDir, "..");
const testsDir = path.join(backendDir, "tests");
const testFiles = fs.readdirSync(testsDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith(".test.mjs"))
.map((entry) => path.join("tests", entry.name))
.sort((left, right) => left.localeCompare(right, "en"));

if (!testFiles.length) throw new Error("未找到后端测试文件。");

const result = spawnSync(process.execPath, ["--test", ...testFiles], {
cwd: backendDir,
encoding: "utf8",
env: process.env,
maxBuffer: 4 * 1024 * 1024,
});
if (result.error) throw result.error;
if (result.status === 0) {
process.stdout.write(result.stdout);
process.stderr.write(result.stderr);
} else {
const lines = String(result.stdout || "").split(/\r?\n/);
const failureBlocks = [];
for (let index = 0; index < lines.length; index += 1) {
if (!/^not ok \d+ - /.test(lines[index])) continue;
const start = index > 0 && lines[index - 1].startsWith("# Subtest:") ? index - 1 : index;
let end = index + 1;
while (end < lines.length && !lines[end].startsWith("# Subtest:") && !/^1\.\./.test(lines[end])) {
end += 1;
}
failureBlocks.push(lines.slice(start, end).join("\n").trimEnd());
index = end - 1;
}
const summary = lines.filter((line) => /^# (?:tests|suites|pass|fail|cancelled|skipped|todo|duration_ms)\b/.test(line));
process.stdout.write([
"后端测试未通过,仅显示失败项与汇总:",
...failureBlocks,
...summary,
"",
].join("\n"));
process.stderr.write(result.stderr);
process.exitCode = result.status || 1;
}
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,13 @@ function assertPrivateSession(filePath) {
if (!session) {
throw new Error("未找到有效 CLI 登录态。请先运行 Skill 的 login.mjs,密码不要发送到聊天或命令行参数。");
}
const mode = fs.statSync(filePath).mode & 0o077;
if (mode !== 0) throw new Error("CLI 会话文件权限不安全;请将其权限改为 0600 后重试。");
const status = fs.lstatSync(filePath);
if (!status.isFile() || status.isSymbolicLink()) {
throw new Error("CLI 会话路径必须是非符号链接的普通文件。");
}
if (process.platform !== "win32" && (status.mode & 0o077) !== 0) {
throw new Error("CLI 会话文件权限不安全;请将其权限改为 0600 后重试。");
}
return session;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,30 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";

const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const backendDir = path.resolve(scriptDir, "..");
const projectRoot = path.resolve(backendDir, "..");
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";

function npmInvocation(args) {
const cli = [
process.env.npm_execpath,
path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"),
].find((candidate) => candidate && /\.(?:c?m?js)$/i.test(candidate) && fs.existsSync(candidate));
if (cli) return { command: process.execPath, args: [cli, ...args] };
if (process.platform === "win32") {
return {
command: process.env.ComSpec || "cmd.exe",
args: ["/d", "/s", "/c", ["npm", ...args].join(" ")],
};
}
return { command: "npm", args };
}

const npmTest = npmInvocation(["test"]);
const npmSecretScan = npmInvocation(["run", "release:secrets"]);

const steps = [
{
Expand All @@ -23,14 +41,14 @@ const steps = [
},
{
name: "后端自动化测试",
command: npmCommand,
args: ["test"],
command: npmTest.command,
args: npmTest.args,
cwd: backendDir,
},
{
name: "发布密钥扫描",
command: npmCommand,
args: ["run", "release:secrets"],
command: npmSecretScan.command,
args: npmSecretScan.args,
cwd: backendDir,
},
{
Expand Down Expand Up @@ -70,6 +88,15 @@ try {
steps.forEach(runStep);
console.log(`\n离线发布验收通过:${steps.length}/${steps.length} 项完成。`);
} catch (error) {
console.error(`\n离线发布验收失败:${error?.message || String(error)}`);
const message = `离线发布验收失败:${error?.message || String(error)}`;
console.error(`\n${message}`);
if (process.env.GITHUB_ACTIONS === "true") {
const annotation = message
.split(projectRoot).join("<repository>")
.replace(/%/g, "%25")
.replace(/\r/g, "%0D")
.replace(/\n/g, "%0A");
process.stderr.write(`::error title=Offline release verification failure::${annotation}\n`);
}
process.exitCode = 1;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync } from "node:fs";
import { delimiter, join } from "node:path";
import { delimiter, isAbsolute, join } from "node:path";
import { createEnvReader } from "./runtimeEnv.js";
import { createRuntimePolicy, publicRuntimePolicy } from "./runtimePolicy.js";

Expand All @@ -17,7 +17,7 @@ function unique(values) {
function commandExists(command) {
const value = String(command || "").trim();
if (!value) return false;
if (value.includes("/")) return existsSync(value);
if (isAbsolute(value) || value.includes("/") || value.includes("\\")) return existsSync(value);
return String(process.env.PATH || "")
.split(delimiter)
.filter(Boolean)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { execFile } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { delimiter, join } from "node:path";
import { delimiter, isAbsolute, join } from "node:path";
import { promisify } from "node:util";
import { createEnvReader } from "../config/runtimeEnv.js";
import { providerFailure, providerSuccess } from "./providerResult.js";
Expand Down Expand Up @@ -48,7 +48,7 @@ function defaultCliConfigPath() {
function commandExists(command) {
const value = String(command || "").trim();
if (!value) return false;
if (value.includes("/")) return existsSync(value);
if (isAbsolute(value) || value.includes("/") || value.includes("\\")) return existsSync(value);
return String(process.env.PATH || "")
.split(delimiter)
.filter(Boolean)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export function createRouter(providerService, options = {}) {
data: {
status: runtimePolicy.ready ? "ok" : "degraded",
service: "sales-intelligence-workbench-api",
version: "0.10.0",
version: "0.10.1",
provider_mode: providerMode,
runtime_ready: runtimePolicy.ready,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ test("backend requests refresh an expired bearer session and rotate the private
const rotated = JSON.parse(fs.readFileSync(sessionFile, "utf8"));
assert.equal(rotated.access_token, "fresh-access");
assert.equal(rotated.refresh_token, "rotated-refresh");
assert.equal(fs.statSync(sessionFile).mode & 0o077, 0);
const sessionStatus = fs.lstatSync(sessionFile);
assert.equal(sessionStatus.isFile(), true);
assert.equal(sessionStatus.isSymbolicLink(), false);
if (process.platform !== "win32") assert.equal(sessionStatus.mode & 0o077, 0);
} finally {
globalThis.fetch = originalFetch;
fs.rmSync(directory, { recursive: true, force: true });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 单工作区自托管部署

本文说明 `0.10.0` 自托管开源版的支持边界和推荐部署方式。当前版本支持单工作区、单管理员,以及本机或受控内网部署;不提供公网托管 SaaS、多人协作或 SLA。公网开放前必须完成正式域名、HTTPS 和真实链路验收。
本文说明 `0.10.1` 自托管开源版的支持边界和推荐部署方式。当前版本支持单工作区、单管理员,以及本机或受控内网部署;不提供公网托管 SaaS、多人协作或 SLA。公网开放前必须完成正式域名、HTTPS 和真实链路验收。

## 1. 进程与网络边界

Expand Down
4 changes: 2 additions & 2 deletions demohouse/sales-intelligence-workbench/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading