diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 00000000..67c2f89d --- /dev/null +++ b/.github/workflows/documentation.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7489d9a9..d702d45a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,7 +17,7 @@ | **Git** | 2.41+ | 源码版本管理 | | **Rust** *(可选)* | 1.97.1+ | 仅在修改或验证 `analyzers/` 目录下的 WASI 代码分析器时需要 | -> **提示**:普通的前后端功能开发不需要真实云端模型凭据;未配置外部模型时,系统将自动使用内置的 Mock Provider 供本地联调。 +> **提示**:启动 API、连接界面和运行离线测试不需要云端模型凭据。真实模型对话需要另行配置模型;测试中的 Mock Provider 不代表已验证真实模型调用。 --- @@ -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): + + ```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 + +```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 均可): + + +```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 diff --git a/scripts/check-contributor-startup.mjs b/scripts/check-contributor-startup.mjs new file mode 100644 index 00000000..a70322eb --- /dev/null +++ b/scripts/check-contributor-startup.mjs @@ -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(``); + 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 }); +}