feat(hub): 真实飞书读取 + 状态卡片 + 权限 gate + 部署脚本

FeishuRead(ADR-0003): feishuContextTool 从 stub 换成真实 lark
  im.v1.message.get(单条 trigger/reply/status_card)+ list(thread 回复);
  ADR-0003 chat 授权不变式不变(handler 已 gate)。
StatusCard: sendCard + buildRunStatusCard——完成/出错发 interactive card
  (status 文案 + model 选择器 + 取消按钮带 runId),替代纯文本。
Permission(ADR-0004, project-wise): triggerAgent 查 PermissionGrant 对
  project 的 edit+ 能力(manage⊇edit 单调);sender open_id 当 principal
  (子类型学 OPEN,务实选择,permission.ts 标注);per-artifact OPEN;
  settings 组合规则 OPEN。无权限回 '无权限触发'。
Deploy: cph-hub.service systemd unit(ExecStartPre 跑 prisma migrate)+
  install_service.sh(idempotent,seed env)+ deploy_platform.sh
  (rsync + npm ci + build + restart + healthz)。
client.ts 抽 createLarkClient/startFeishuListenerWithClient,tools 与
  ws 共用同一 client。
tsc rc=0;prisma validate 通过;deploy 脚本 bash -n 通过。
This commit is contained in:
2026-07-06 23:44:07 +08:00
parent a4d52e1850
commit ce767e9cab
11 changed files with 433 additions and 30 deletions
+9
View File
@@ -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"
+21
View File
@@ -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
+47
View File
@@ -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)"
+62
View File
@@ -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" <<EOF
NODE_ENV=production
DATABASE_URL=postgresql://paradigm:change-me@127.0.0.1:5432/paradigm
OPENROUTER_API_KEY=
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_BOT_OPEN_ID=
PORT=$PORT
HOST=$HOST
EOF
echo "Seeded $ENV_FILE — edit it to fill in OPENROUTER_API_KEY and Feishu creds, then re-run."
exit 0
fi
# Render the unit with concrete paths.
UNIT=/etc/systemd/system/cph-hub.service
sed \
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \
-e "s|__HUB_DIR__|$HUB_DIR|g" \
-e "s|__ENV_FILE__|$ENV_FILE|g" \
-e "s|__NODE_BIN__|$NODE_BIN|g" \
"$HUB_DIR/deploy/cph-hub.service" >"$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"
+2 -1
View File
@@ -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"
}
}
+3 -2
View File
@@ -96,7 +96,8 @@ export interface FeishuContextArgs {
* `@larksuiteoapi/node-sdk`.
*/
export function feishuContextTool(
read: (args: FeishuContextArgs, ctx: ToolContext) => Promise<string>,
read: (args: FeishuContextArgs, ctx: ToolContext, rt: unknown) => Promise<string>,
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);
},
};
}
+80 -9
View File
@@ -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<string | null> {
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<void>,
): 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<void>;
}
/** 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<void>,
): 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<void>,
): FeishuRuntime {
return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage);
}
+97
View File
@@ -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<string> {
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<string, unknown> {
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,
};
}
+25 -3
View File
@@ -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);
+75
View File
@@ -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<PermissionRole> = 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<PermissionResult> {
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);
}
+12 -15
View File
@@ -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<void> {
],
);
// 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<void> {
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) {