diff --git a/hub/.env.example b/hub/.env.example index ee012de..727f57e 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -12,3 +12,12 @@ OPENROUTER_API_KEY="" # Hub server port. Defaults to 8788. PORT=8788 + +# Feishu (lark) bot credentials. The bot receives @mentions in project groups. +FEISHU_APP_ID="" +FEISHU_APP_SECRET="" +FEISHU_BOT_OPEN_ID="" + +# Path to the `cph` binary (the Courseware-side checker, ADR-0016). +# Defaults to `cph` on PATH if unset. +# CPH_BIN="/usr/local/bin/cph" diff --git a/hub/deploy/cph-hub.service b/hub/deploy/cph-hub.service new file mode 100644 index 0000000..a315659 --- /dev/null +++ b/hub/deploy/cph-hub.service @@ -0,0 +1,21 @@ +[Unit] +Description=Curriculum Project Hub (Feishu agent + cph) +After=network-online.target postgresql.service +Wants=network-online.target + +[Service] +Type=simple +User=__SERVICE_USER__ +WorkingDirectory=__HUB_DIR__ +EnvironmentFile=__ENV_FILE__ +# Run prisma migrations before each start, then launch the Hub. +ExecStartPre=__NODE_BIN__ __HUB_DIR__/node_modules/prisma/build/index.js migrate deploy --schema __HUB_DIR__/prisma/schema.prisma +ExecStart=__NODE_BIN__ __HUB_DIR__/dist/server.js +Restart=on-failure +RestartSec=5 +# Graceful shutdown: lark ws close + in-flight runs. +KillSignal=SIGTERM +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/hub/deploy/deploy_platform.sh b/hub/deploy/deploy_platform.sh new file mode 100755 index 0000000..b4ab2b7 --- /dev/null +++ b/hub/deploy/deploy_platform.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Deploy the Hub to a host: rsync the repo, install deps, build, restart service. +# +# Configure these env vars (e.g. in CI secrets): +# PLATFORM_DEPLOY_HOST required +# PLATFORM_DEPLOY_SSH_KEY required (private key for the deploy user) +# PLATFORM_DEPLOY_USER optional, defaults to deploy +# PLATFORM_DEPLOY_PORT optional, defaults to 22 +# PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub +# +# The target host must have Node.js, npm, rsync, PostgreSQL, systemd, and a +# writable $PLATFORM_DEPLOY_BASE. Use a dedicated `deploy` user that has +# passwordless sudo for: +# systemctl restart cph-hub.service +# systemctl is-active --quiet cph-hub.service +set -euo pipefail + +HOST="${PLATFORM_DEPLOY_HOST:?PLATFORM_DEPLOY_HOST required}" +SSH_KEY="${PLATFORM_DEPLOY_SSH_KEY:?PLATFORM_DEPLOY_SSH_KEY required}" +DEPLOY_USER="${PLATFORM_DEPLOY_USER:-deploy}" +PORT="${PLATFORM_DEPLOY_PORT:-22}" +BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}" +HUB_DIR="$BASE/hub" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +SSH_OPTS=(-i "$SSH_KEY" -p "$PORT" -o StrictHostKeyChecking=accept-new) + +# 1. Rsync the hub/ directory (exclude node_modules/dist, they rebuild on host). +rsync -az --delete \ + --exclude node_modules --exclude dist --exclude .env \ + -e "ssh ${SSH_OPTS[*]}" \ + "$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/" + +# 2. Install deps + build on the host. +ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "cd '$HUB_DIR' && npm ci && npm run build" + +# 3. Ensure the service is installed (idempotent), then restart. +ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" " + BASE='$BASE' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/install_service.sh' || true + sudo systemctl restart cph-hub.service + sleep 2 + sudo systemctl is-active --quiet cph-hub.service || { echo 'service failed to start'; exit 1; } + curl -sf http://127.0.0.1:8788/api/healthz || { echo 'healthz failed'; exit 1; } +" + +echo "Deployed cph-hub to $HOST ($HUB_DIR)" diff --git a/hub/deploy/install_service.sh b/hub/deploy/install_service.sh new file mode 100755 index 0000000..867fb72 --- /dev/null +++ b/hub/deploy/install_service.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Install the cph-hub systemd service on a host. Idempotent. +# +# Prerequisites already on the host: Node.js, npm, PostgreSQL, systemd. +# The Hub repo is expected at HUB_DIR (default /srv/curriculum-project-hub/hub) +# with `npm ci` + `npm run build` already run by deploy_platform.sh. +# +# Usage: +# sudo BASE=/srv/curriculum-project-hub bash deploy/install_service.sh +set -euo pipefail + +BASE="${BASE:-/srv/curriculum-project-hub}" +HUB_DIR="${HUB_DIR:-$BASE/hub}" +SERVICE_USER="${SERVICE_USER:-root}" +HOST="${HOST:-127.0.0.1}" +PORT="${PORT:-8788}" +ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}" +NODE_BIN="${NODE_BIN:-$(command -v node || true)}" + +if [ -z "$NODE_BIN" ]; then + echo "node must be on PATH, or set NODE_BIN." >&2 + exit 1 +fi +if [ ! -d "$HUB_DIR" ]; then + echo "HUB_DIR not found: $HUB_DIR (set HUB_DIR or clone the repo there)." >&2 + exit 1 +fi +if [ ! -f "$HUB_DIR/dist/server.js" ]; then + echo "build missing: run 'npm ci && npm run build' in $HUB_DIR first." >&2 + exit 1 +fi + +# Seed the env file if absent. Secrets stay on the server, never in the repo. +if [ ! -f "$ENV_FILE" ]; then + install -d -m 0700 "$(dirname "$ENV_FILE")" + cat >"$ENV_FILE" <"$UNIT" + +systemctl daemon-reload +systemctl enable cph-hub.service +echo "Installed cph-hub.service. Start with: systemctl start cph-hub" +echo "Check health: curl http://127.0.0.1:$PORT/api/healthz" diff --git a/hub/package.json b/hub/package.json index c13073f..01ba85b 100644 --- a/hub/package.json +++ b/hub/package.json @@ -27,6 +27,7 @@ "check": "tsc -p tsconfig.json --noEmit", "prisma:generate": "prisma generate --schema prisma/schema.prisma", "prisma:validate": "DATABASE_URL=${DATABASE_URL:-postgresql://stub:stub@127.0.0.1:5432/stub} prisma validate --schema prisma/schema.prisma", - "prisma:migrate": "DATABASE_URL=${DATABASE_URL:-postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm} prisma migrate deploy --schema prisma/schema.prisma" + "prisma:migrate": "DATABASE_URL=${DATABASE_URL:-postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm} prisma migrate deploy --schema prisma/schema.prisma", + "deploy": "bash deploy/deploy_platform.sh" } } diff --git a/hub/src/agent/tools.ts b/hub/src/agent/tools.ts index eed6198..2790b70 100644 --- a/hub/src/agent/tools.ts +++ b/hub/src/agent/tools.ts @@ -96,7 +96,8 @@ export interface FeishuContextArgs { * `@larksuiteoapi/node-sdk`. */ export function feishuContextTool( - read: (args: FeishuContextArgs, ctx: ToolContext) => Promise, + read: (args: FeishuContextArgs, ctx: ToolContext, rt: unknown) => Promise, + rt: unknown, ): RegisteredTool { return { spec: { @@ -122,7 +123,7 @@ export function feishuContextTool( if (a.chat_id !== ctx.boundChatId) { throw new UnauthorizedChatRead(a.chat_id, ctx.boundChatId); } - return read(a, ctx); + return read(a, ctx, rt); }, }; } diff --git a/hub/src/feishu/client.ts b/hub/src/feishu/client.ts index a05344e..e7c3570 100644 --- a/hub/src/feishu/client.ts +++ b/hub/src/feishu/client.ts @@ -61,31 +61,96 @@ export async function sendText(rt: FeishuRuntime, chatId: string, text: string): }); } +/** Send an interactive card to a chat. `card` is the lark card schema object. */ +export async function sendCard( + rt: FeishuRuntime, + chatId: string, + card: unknown, +): Promise { + const client = rt.client as unknown as { + im: { v1: { message: { create: (p: unknown) => Promise<{ data?: { message_id?: string } }> } } }; + }; + const res = await client.im.v1.message.create({ + params: { receive_id_type: "chat_id" }, + data: { + receive_id: chatId, + msg_type: "interactive", + content: JSON.stringify(card), + }, + }); + return res.data?.message_id ?? null; +} + +/** Build a run-status card: status text + model selector + cancel button. */ +export function buildRunStatusCard(opts: { + readonly status: string; + readonly model: string; + readonly models: readonly { readonly id: string; readonly label: string }[]; + readonly runId: string; +}): unknown { + return { + config: { wide_screen_mode: true }, + header: { + title: { tag: "plain_text", content: `@Claude · ${opts.status}` }, + template: opts.status === "处理完成" ? "green" : opts.status === "处理出错" ? "red" : "blue", + }, + elements: [ + { tag: "div", text: { tag: "lark_md", content: `**model:** ${opts.model}` } }, + { + tag: "action", + actions: [ + { + tag: "select", + placeholder: { tag: "plain_text", content: "切换 model" }, + options: opts.models.map((m) => ({ text: { tag: "plain_text", content: m.label }, value: m.id })), + value: opts.model, + }, + { + tag: "button", + text: { tag: "plain_text", content: "取消" }, + type: "danger", + value: JSON.stringify({ action: "cancel", runId: opts.runId }), + }, + ], + }, + ], + }; +} + /** * Start the lark WebSocket client and route `im.message.receive_v1` events to * `onMessage`. The ws connection lives in the background; this resolves the * runtime handle. */ -export function startFeishuListener( - config: FeishuConfig, - logger: FastifyBaseLogger, - onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise, -): FeishuRuntime { - const client = new lark.Client({ +/** Build a lark Client (HTTP API surface). Used before the ws starts, so tools + * that need to call Feishu APIs can hold it from server boot. */ +export function createLarkClient(config: FeishuConfig): lark.Client { + return new lark.Client({ appId: config.appId, appSecret: config.appSecret, appType: lark.AppType.SelfBuild, }); +} +export interface FeishuListener { + readonly runtime: FeishuRuntime; + readonly stop: () => Promise; +} + +/** Start the ws listener using an already-built client. Returns the runtime. */ +export function startFeishuListenerWithClient( + config: FeishuConfig, + client: lark.Client, + logger: FastifyBaseLogger, + onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise, +): FeishuRuntime { const wsClient = new lark.WSClient({ appId: config.appId, appSecret: config.appSecret, domain: lark.Domain.Feishu, loggerLevel: lark.LoggerLevel.info, }); - const rt: FeishuRuntime = { client, logger }; - void wsClient.start({ eventDispatcher: new lark.EventDispatcher({}).register({ "im.message.receive_v1": async (data) => { @@ -97,6 +162,12 @@ export function startFeishuListener( }, }), }); - return rt; } +export function startFeishuListener( + config: FeishuConfig, + logger: FastifyBaseLogger, + onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise, +): FeishuRuntime { + return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage); +} diff --git a/hub/src/feishu/read.ts b/hub/src/feishu/read.ts new file mode 100644 index 0000000..d035aa4 --- /dev/null +++ b/hub/src/feishu/read.ts @@ -0,0 +1,97 @@ +/** + * Feishu context reader — the real implementation behind `feishuContextTool`. + * + * ADR-0003: Hub stores only anchors, reads Feishu context on demand. The tool + * (`feishu_read_context`) is already chat-authorized by `tools.ts` (chat must + * match the project binding). Here we actually call lark's `im.v1.message`: + * + * - `trigger_message` / `reply`: `message.get` by message_id. + * - `status_card`: the run's status card message — same `message.get` by id. + * - `thread`: lark's thread replies. The SDK exposes `message.list` with a + * `parent_message_id` filter; we map "thread" to that. + * + * The lark SDK's `im.v1.message` methods are dynamic at runtime (weak types); + * we cast through a known request/response shape and return a compact JSON for + * the model. All calls use the chat-authorized `client` from the runtime — no + * separate credential path, so the ADR-0003 invariant holds end-to-end. + */ +import type { FeishuRuntime } from "./client.js"; +import type { FeishuContextArgs } from "../agent/tools.js"; +import type { ToolContext } from "../agent/tools.js"; + +/** Pragmatic shape of the lark message object we consume. */ +interface LarkMessage { + message_id?: string; + msg_type?: string; + content?: string; + create_time?: string; + sender?: { id?: string; sender_type?: string }; + chat_id?: string; + parent_message_id?: string; + root_id?: string; +} + +interface ImV1Message { + get: (p: { path: { message_id: string } }) => Promise<{ data?: { items?: LarkMessage[]; message?: LarkMessage } }>; + list: (p: { + params: { container_id_type: string; container_id: string; page_size?: number }; + }) => Promise<{ data?: { items?: LarkMessage[] } }>; +} + +function im(rt: FeishuRuntime): ImV1Message { + return (rt.client as unknown as { im: { v1: { message: ImV1Message } } }).im.v1.message; +} + +/** Read on-demand Feishu context for the current run. Entry point for the tool. */ +export async function readFeishuContext( + args: FeishuContextArgs, + _ctx: ToolContext, + rt: unknown, +): Promise { + const runtime = rt as FeishuRuntime; + const api = im(runtime); + try { + switch (args.anchor) { + case "trigger_message": + case "status_card": + case "reply": { + // All three are single-message reads by id. + const res = await api.get({ path: { message_id: args.id } }); + const msg = res.data?.message ?? res.data?.items?.[0]; + if (msg === undefined) return JSON.stringify({ error: "message not found", id: args.id }); + return JSON.stringify(compact(msg)); + } + case "thread": { + // Thread = replies to a parent message. `container_id` is the parent's + // message_id; container_id_type=message_id scopes the list to that thread. + const res = await api.list({ + params: { + container_id_type: "message_id", + container_id: args.id, + page_size: 50, + }, + }); + const items = res.data?.items ?? []; + return JSON.stringify(items.map(compact)); + } + default: + return JSON.stringify({ error: `unknown anchor type: ${args.anchor as string}` }); + } + } catch (e) { + return JSON.stringify({ error: e instanceof Error ? e.message : String(e) }); + } +} + +/** Project a lark message down to the fields the model actually needs. */ +function compact(m: LarkMessage): Record { + return { + message_id: m.message_id, + msg_type: m.msg_type, + content: m.content, + create_time: m.create_time, + sender_id: m.sender?.id, + parent_message_id: m.parent_message_id, + root_id: m.root_id, + }; +} + diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index 055b8e8..7a9ff4e 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -15,12 +15,13 @@ */ import type { PrismaClient } from "@prisma/client"; import type { FastifyBaseLogger } from "fastify"; -import { sendText, type FeishuRuntime, type MessageReceiveEvent } from "./client.js"; +import { sendText, sendCard, buildRunStatusCard, type FeishuRuntime, type MessageReceiveEvent } from "./client.js"; import type { AgentProvider } from "../agent/provider.js"; import type { ToolRegistry } from "../agent/tools.js"; import type { ModelRegistry } from "../agent/models.js"; import { runAgent, type ProjectContext } from "../agent/runner.js"; import { acquireLock, currentLockRunId, releaseLock } from "../lock.js"; +import { canTriggerAgent } from "../permission.js"; interface TriggerDeps { readonly prisma: PrismaClient; @@ -56,6 +57,17 @@ export function makeTriggerHandler(deps: TriggerDeps) { return; } const projectId = binding.projectId; + // ADR-0004: triggerAgent requires edit on the project. principal=sender + // open_id (sub-typology OPEN — pragmatic choice, see permission.ts). + const senderOpenId = event.sender.sender_id.open_id ?? ""; + if (senderOpenId !== "") { + const perm = await canTriggerAgent(deps.prisma, projectId, senderOpenId); + if (!perm.allowed) { + deps.logger.info({ projectId, senderOpenId, reason: perm.reason }, "feishu trigger: permission denied"); + await sendText(rt, chatId, "无权限触发。"); + return; + } + } // ADR-0002: if locked by another run, reply busy. const existing = await currentLockRunId(deps.prisma, projectId); @@ -160,14 +172,24 @@ export function makeTriggerHandler(deps: TriggerDeps) { finishedAt: new Date(), }, }); - await sendText(rt, chatId, statusReply(result.status)); + await sendCard(rt, chatId, buildRunStatusCard({ + status: statusReply(result.status), + model, + models: deps.models.listModels(), + runId: run.id, + })); }) .catch(async (e) => { await deps.prisma.agentRun.update({ where: { id: run.id }, data: { status: "FAILED", error: String(e), finishedAt: new Date() }, }); - await sendText(rt, chatId, "处理出错,已释放项目锁。"); + await sendCard(rt, chatId, buildRunStatusCard({ + status: "处理出错", + model, + models: deps.models.listModels(), + runId: run.id, + })); }) .finally(async () => { await releaseLock(deps.prisma, run.id); diff --git a/hub/src/permission.ts b/hub/src/permission.ts new file mode 100644 index 0000000..017ffb9 --- /dev/null +++ b/hub/src/permission.ts @@ -0,0 +1,75 @@ +/** + * Permission gate — ADR-0004 in code, project-scope. + * + * `triggerAgent` requires the `edit` capability (ADR-0004). `edit` is granted + * by a `PermissionGrant` with role `edit` or `manage` (manage ⊇ edit, spec + * `Role.can_mono`). This module checks that for the run's requester against + * the project resource. + * + * Scope decision (project-wise): the resource is `project`, not + * `project_group` — the run and lock scope to the project (ADR-0002), so the + * capability is checked against the project. per-artifact permission is `OPEN` + * pending a Courseware-side artifact id (ADR-0004 + ADR-0007 mapping). + * + * principal→user mapping is `OPEN` (ADR-0004 principal sub-typology). Here we + * use the sender's Feishu open_id as the principal string literal — a pragmatic + * choice for the first cut, not a spec decision. When a real principal model + * lands, this is the single seam to change. + * + * settings.agentTrigger composition is `OPEN` (ADR-0004 separates role-derived + * capabilities from settings policy knobs but doesn't pin the composition + * rule). This gate implements the role half only; settings composition is + * deferred and clearly marked. + */ +import type { PrismaClient } from "@prisma/client"; +import type { PermissionRole } from "@prisma/client"; + +/// The roles that grant `edit` capability (edit itself + manage, by monotonicity). +const EDIT_OR_HIGHER: ReadonlySet = new Set(["EDIT", "MANAGE"]); + +export interface PermissionResult { + readonly allowed: boolean; + readonly reason: string; +} + +/** + * Check whether `principal` may trigger an agent run on `projectId`. + * + * Returns `{allowed, reason}`. On allow, the caller proceeds; on deny, the + * caller replies with the reason. Never throws — a DB error is a deny with the + * error as reason, so the run is not started on a broken state. + */ +export async function canTriggerAgent( + prisma: PrismaClient, + projectId: string, + principal: string, +): Promise { + try { + // Look for an active (non-revoked) grant on the project resource for this + // principal, with a role that grants edit or higher. + const grant = await prisma.permissionGrant.findFirst({ + where: { + resourceType: "PROJECT", + resourceId: projectId, + principal, + role: { in: ["EDIT", "MANAGE"] }, + revokedAt: null, + }, + select: { id: true, role: true }, + }); + if (grant !== null) { + return { allowed: true, reason: `granted by role ${grant.role}` }; + } + return { + allowed: false, + reason: `no active edit+ grant for ${principal} on project ${projectId}`, + }; + } catch (e) { + return { allowed: false, reason: `permission check failed: ${e instanceof Error ? e.message : String(e)}` }; + } +} + +/// Whether a role grants edit capability (edit or manage). Exported for tests. +export function roleGrantsEdit(role: PermissionRole): boolean { + return EDIT_OR_HIGHER.has(role); +} diff --git a/hub/src/server.ts b/hub/src/server.ts index cd11b2f..1a3dc89 100644 --- a/hub/src/server.ts +++ b/hub/src/server.ts @@ -18,7 +18,8 @@ import { InMemoryModelRegistry } from "./agent/models.js"; import { ToolRegistry, feishuContextTool } from "./agent/tools.js"; import { readFileTool, writeFileTool, listFilesTool } from "./agent/workspace.js"; import { cphCheckTool, cphBuildTool } from "./agent/cph.js"; -import { startFeishuListener } from "./feishu/client.js"; +import { createLarkClient, startFeishuListenerWithClient, type FeishuRuntime } from "./feishu/client.js"; +import { readFeishuContext } from "./feishu/read.js"; import { makeTriggerHandler } from "./feishu/trigger.js"; function requireEnv(name: string): string { @@ -56,14 +57,15 @@ async function main(): Promise { ], ); + // Build the lark client first — tools that call Feishu APIs hold it from boot. + const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId }; + const larkClient = createLarkClient(feishuConfig); + const feishuRt: FeishuRuntime = { client: larkClient, logger: app.log }; + const tools = new ToolRegistry(); - tools.register( - // ADR-0003: the handler enforces chat==boundChat before any Feishu API call. - // Real impl wraps @larksuiteoapi/node-sdk's im.message.list/read APIs. - feishuContextTool(async (args, ctx) => { - return JSON.stringify({ stub: true, anchor: args.anchor, id: args.id, projectId: ctx.projectId }); - }), - ); + // ADR-0003: the handler enforces chat==boundChat before any API call; + // real read wraps @larksuiteoapi/node-sdk's im.v1.message get/list. + tools.register(feishuContextTool(readFeishuContext, feishuRt)); // Workspace file tools (ADR-0007 directory tree, path-confined). tools.register(readFileTool()); tools.register(writeFileTool()); @@ -72,14 +74,9 @@ async function main(): Promise { tools.register(cphCheckTool()); tools.register(cphBuildTool()); - // --- Feishu listener --- + // --- Feishu listener (reuses the same lark client) --- const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: app.log }); - const feishuCtx = startFeishuListener( - { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId }, - app.log, - trigger, - ); - void feishuCtx; + startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger); app.listen({ port, host: "0.0.0.0" }, (err, address) => { if (err !== null) {