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
68 changes: 68 additions & 0 deletions .github/workflows/documentation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: Documentation checks

on:
pull_request:
paths:
- 'CONTRIBUTING.md'
- 'scripts/check-contributor-startup.mjs'
- '.github/workflows/documentation.yml'
- 'internal/app/api_command.go'
- 'internal/httpapi/server.go'
- 'web/src/components/connection-gate.tsx'
- 'web/vite.config.ts'
- 'web/package*.json'
- 'go.mod'
- 'go.sum'
push:
branches: [main]
paths:
- 'CONTRIBUTING.md'
- 'scripts/check-contributor-startup.mjs'
- '.github/workflows/documentation.yml'
- 'internal/app/api_command.go'
- 'internal/httpapi/server.go'
- 'web/src/components/connection-gate.tsx'
- 'web/vite.config.ts'
- 'web/package*.json'
- 'go.mod'
- 'go.sum'
workflow_dispatch:

permissions:
contents: read

concurrency:
group: documentation-${{ github.ref }}
cancel-in-progress: true

jobs:
startup:
name: Contributor startup (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24.22.1'
cache: npm
cache-dependency-path: web/package-lock.json
- name: Follow the documented dependency preparation
run: |
go mod download
go mod verify
cd web
npm ci
- name: Install smoke-test browser
working-directory: web
run: npx playwright install --with-deps chromium
- name: Execute documented shell commands and connect in Chromium
run: node scripts/check-contributor-startup.mjs
65 changes: 43 additions & 22 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
| **Git** | 2.41+ | 源码版本管理 |
| **Rust** *(可选)* | 1.97.1+ | 仅在修改或验证 `analyzers/` 目录下的 WASI 代码分析器时需要 |

> **提示**:普通的前后端功能开发不需要真实云端模型凭据;未配置外部模型时,系统将自动使用内置的 Mock Provider 供本地联调。
> **提示**:启动 API、连接界面和运行离线测试不需要云端模型凭据。真实模型对话需要另行配置模型;测试中的 Mock Provider 不代表已验证真实模型调用。

---

Expand All @@ -35,42 +35,63 @@ cd web
npm ci
cd ..

# 校验并同步 Go 模块依赖
# 下载并校验锁定的 Go 模块依赖(verify 本身不会下载缺失模块)
go mod download
go mod verify
```

### 2. 启动前端热重载开发服务器
### 2. 在终端 A 启动 Go 后端

在 `web` 目录下运行:
以下命令均从仓库根目录执行。两个 Token 必须不同,且各为 **32–512 字节**、不含空白。使用已安装的 Node.js 分别生成 32 个随机字节,编码后各为 64 字节的十六进制文本。每次执行都会生成新值。

Linux / macOS(Bash):

<!-- dev-backend:bash -->
```bash
cd web
npm run dev
# 默认开发地址为 http://127.0.0.1:5173
export CYBERAGENT_API_TOKEN="$(node -p "require('node:crypto').randomBytes(32).toString('hex')")"
export CYBERAGENT_API_CONTROL_TOKEN="$(node -p "require('node:crypto').randomBytes(32).toString('hex')")"
export CYBERAGENT_HOME="${CYBERAGENT_HOME:-$PWD/.cyberagent-workbench/dev}"
node -e "console.log('Read token: ' + process.env.CYBERAGENT_API_TOKEN); console.log('Control token: ' + process.env.CYBERAGENT_API_CONTROL_TOKEN)"
go run ./cmd/cyberagent api serve --listen 127.0.0.1:8765
```

前端开发服务器已配置自动反向代理,会自动将 `/api` 请求转发到本地运行的 Go 后端服务(默认目标为 `http://127.0.0.1:8765`)。

### 3. 运行 Go 后端 API 服务
Windows(PowerShell):

在仓库根目录下运行:

```bash
<!-- dev-backend:powershell -->
```powershell
$env:CYBERAGENT_API_TOKEN = node -p "require('node:crypto').randomBytes(32).toString('hex')"
$env:CYBERAGENT_API_CONTROL_TOKEN = node -p "require('node:crypto').randomBytes(32).toString('hex')"
if (-not $env:CYBERAGENT_HOME) { $env:CYBERAGENT_HOME = Join-Path (Get-Location).Path '.cyberagent-workbench/dev' }
node -e "console.log('Read token: ' + process.env.CYBERAGENT_API_TOKEN); console.log('Control token: ' + process.env.CYBERAGENT_API_CONTROL_TOKEN)"
go run ./cmd/cyberagent api serve --listen 127.0.0.1:8765
```

如需配置只读访问 Token 或控制 Token,可在启动前注入环境变量:
```bash
# Windows PowerShell
$env:CYBERAGENT_API_TOKEN = "local-read-token"
$env:CYBERAGENT_API_CONTROL_TOKEN = "local-control-token"
首次编译可能需要数分钟。看到 `api_url: http://127.0.0.1:8765/api/v1` 后保持终端运行。未另设 `CYBERAGENT_HOME` 时,开发数据保存在已被 Git 忽略的 `.cyberagent-workbench/dev`,不使用桌面应用的数据目录。上面打印的令牌仅用于下一步本机连接,请勿提交或粘贴到 Issue、截图、日志附件中。

### 3. 在终端 B 启动前端并连接

# Linux / macOS
export CYBERAGENT_API_TOKEN="local-read-token"
export CYBERAGENT_API_CONTROL_TOKEN="local-control-token"
新开终端,从同一仓库根目录执行(Bash / PowerShell 均可):

<!-- dev-frontend -->
```sh
cd web
npm run dev -- --host 127.0.0.1 --port 5173 --strictPort
```

### 4. 运行桌面版客户端
1. 打开 **http://127.0.0.1:5173**,在“查看令牌 / Read access token”中填入终端 A 打印的 `Read token`。
2. 如需创建任务或操作,再在“操作令牌 / Control access token”中填入 `Control token`,点击“连接 / Connect”。不要将 Token 写入 URL、前端 `.env` 或源码。
3. 成功后进入工作台;浏览器网络面板中的 `/api/v1/health` 和 `/api/v1/capabilities` 应返回 200。前端通过同源 `/api` 代理到 `http://127.0.0.1:8765`,浏览器无需直接访问后端端口。
4. 在两个终端分别按 `Ctrl+C` 停止服务。重新生成 Token 后需要重新连接;开发数据会保留。

端口占用时先停止旧的开发进程。`--strictPort` 可防止前端悄悄改用其他端口。若自行更改后端端口,须在启动 Vite 的终端设置 `CYBERAGENT_API_TARGET` 为对应的 loopback URL。

该命令用于基本界面/API 联调。工作区导入、模型凭据写入、后台执行等功能还需要对应的显式启动开关;控制 Token 本身不会开启全部能力。按需查阅 `go run ./cmd/cyberagent api serve --help` 和[使用指南](docs/usage.md)。

CI 会直接提取上述代码块,在 Linux/Bash 与 Windows/PowerShell 上启动真实 Go API、Vite 和 Chromium,验证令牌、代理与连接页。依赖准备完成后,本地可运行 `cd web`、`npx playwright install chromium`,回到根目录运行 `node scripts/check-contributor-startup.mjs`。

### 4. 可选:运行原生桌面客户端

这是独立的开发入口,使用内嵌前后端;完成上面的浏览器联调不需要构建桌面应用。

- **Windows 桌面端 (需要 Edge WebView2 Runtime)**:
```powershell
Expand Down
124 changes: 124 additions & 0 deletions scripts/check-contributor-startup.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Execute the actual CONTRIBUTING snippets against an isolated store and browser.
// Prerequisites: npm ci in web, go mod download, playwright install chromium.
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { createRequire } from "node:module";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { setTimeout as delay } from "node:timers/promises";

const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const require = createRequire(join(root, "web/package.json"));
const { chromium } = require("@playwright/test");
const windows = process.platform === "win32";
const markdown = await readFile(join(root, "CONTRIBUTING.md"), "utf8");
function snippet(marker) {
const pieces = markdown.split(`<!-- ${marker} -->`);
assert.equal(pieces.length, 2, `Missing or duplicated documentation snippet: ${marker}`);
const match = pieces[1].match(/^\s*```\w+\n([\s\S]*?)\n```/);
assert.ok(match, `Missing code fence: ${marker}`);
return match[1];
}

// Never connect to or stop somebody else's development server.
for (const port of [8765, 5173]) {
await new Promise((accept, reject) => {
const probe = createServer();
probe.once("error", reject);
probe.listen(port, "127.0.0.1", () => probe.close(accept));
});
}
const home = await mkdtemp(join(tmpdir(), "contributor-startup-"));
const children = [];
let browser;
function start(command) {
const env = { ...process.env, CYBERAGENT_HOME: home, NO_COLOR: "1" };
for (const key of ["CYBERAGENT_API_TARGET", "VITE_API_BASE_URL", "CYBERAGENT_API_TOKEN", "CYBERAGENT_API_CONTROL_TOKEN"]) {
delete env[key];
}
// Windows PowerShell 5.1 also exercises legacy native-argument quoting.
const child = spawn(windows ? "powershell.exe" : "bash",
windows ? ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "$ErrorActionPreference = 'Stop'\n" + command]
: ["-e", "-o", "pipefail", "-c", command],
{ cwd: root, env, detached: !windows, stdio: ["ignore", "pipe", "pipe"] });
const state = { child, output: "", error: null };
// Keep token-bearing output private. Only sanitized diagnostics leave this process.
child.stdout.on("data", data => { state.output += data.toString(); });
child.stderr.on("data", data => { state.output += data.toString(); });
child.on("error", error => { state.error = error; });
children.push(state);
return state;
}
function sanitized(text) {
return text.replace(/(?:Read|Control) token:[^\r\n]*/g, "token: [redacted]")
.replace(/\b[0-9a-f]{64}\b/g, "[redacted]").slice(-4000);
}
async function ready(state, predicate) {
const deadline = Date.now() + 300_000;
while (Date.now() < deadline) {
if (state.error) throw state.error;
if (state.child.exitCode !== null) throw new Error("Documented command exited: " + sanitized(state.output));
if (await predicate()) return;
await delay(200);
}
throw new Error("Documented startup timed out: " + sanitized(state.output));
}

try {
const backend = start(snippet(`dev-backend:${windows ? "powershell" : "bash"}`));
await ready(backend, () => backend.output.includes("api_url: http://127.0.0.1:8765/api/v1"));
const read = backend.output.match(/^Read token: (\S+)\r?$/m)?.[1];
const control = backend.output.match(/^Control token: (\S+)\r?$/m)?.[1];
// Do not use assertions that print secret values on failure.
assert.ok(/^[0-9a-f]{64}$/.test(read), "Read token must encode 32 random bytes");
assert.ok(/^[0-9a-f]{64}$/.test(control), "Control token must encode 32 random bytes");
assert.ok(read !== control, "Read and control tokens must differ");
const frontend = start(snippet("dev-frontend"));
await ready(frontend, async () => {
try { return (await fetch("http://127.0.0.1:5173", { signal: AbortSignal.timeout(1000) })).ok; }
catch { return false; }
});
for (const origin of ["http://127.0.0.1:8765", "http://127.0.0.1:5173"]) {
for (const path of ["health", "capabilities"]) {
const url = `${origin}/api/v1/${path}`;
const options = token => ({ headers: token ? { Authorization: `Bearer ${token}` } : {}, signal: AbortSignal.timeout(5000) });
assert.equal((await fetch(url, options())).status, 401, "Anonymous access must be rejected");
assert.equal((await fetch(url, options(control))).status, 401, "Control token must not grant read access");
assert.equal((await fetch(url, options(read))).status, 200, "Read access must work directly and through Vite");
}
}
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ locale: "zh-CN" });
await page.goto("http://127.0.0.1:5173");
await page.locator("#read-token").fill(read);
await page.locator("#control-token").fill(control);
const responses = ["health", "capabilities"].map(path => page.waitForResponse(
response => response.url() === `http://127.0.0.1:5173/api/v1/${path}` && response.status() === 200));
await page.locator(".connection-panel button[type=submit]").click();
await Promise.all(responses);
await page.locator(".connection-page").waitFor({ state: "detached" });
await page.getByRole("button", { name: "新对话", exact: true }).waitFor({ state: "visible" });
assert.ok(!page.url().includes(read) && !page.url().includes(control), "Tokens must not enter the browser URL");
console.log(`contributor-startup: ${windows ? "PowerShell" : "Bash"} snippets, token boundaries, Go API, Vite proxy and browser connection passed`);
} catch (error) {
console.error(sanitized(String(error.stack ?? error)));
process.exitCode = 1;
} finally {
await browser?.close();
for (const { child } of children.reverse()) {
if (!child.pid) continue;
if (windows) spawnSync("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" });
else {
try { process.kill(-child.pid, "SIGTERM"); } catch (error) { if (error.code !== "ESRCH") throw error; }
}
}
for (const { child } of children) {
if (child.exitCode === null && child.signalCode === null) {
await new Promise(accept => child.once("exit", accept));
}
}
await rm(home, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
}
Loading