@jesonliu/lark-claudecode-bridge 1.3.6 → 1.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -1
- package/assets/web/js/main.js +2 -1
- package/assets/web/js/pages/memory.js +543 -0
- package/config.example.yaml +51 -11
- package/dist/config.d.ts +3 -0
- package/dist/config.js +213 -14
- package/dist/config.js.map +1 -1
- package/dist/executor/claude-executor.d.ts +34 -4
- package/dist/executor/claude-executor.js +718 -282
- package/dist/executor/claude-executor.js.map +1 -1
- package/dist/executor/notify-server.d.ts +10 -0
- package/dist/executor/notify-server.js +11 -0
- package/dist/executor/notify-server.js.map +1 -1
- package/dist/executor/permission-gate.d.ts +38 -0
- package/dist/executor/permission-gate.js +53 -0
- package/dist/executor/permission-gate.js.map +1 -1
- package/dist/gateway/card-builder.d.ts +15 -3
- package/dist/gateway/card-builder.js +21 -10
- package/dist/gateway/card-builder.js.map +1 -1
- package/dist/gateway/feishu-gateway.d.ts +36 -2
- package/dist/gateway/feishu-gateway.js +105 -8
- package/dist/gateway/feishu-gateway.js.map +1 -1
- package/dist/gateway/progress-card.d.ts +4 -2
- package/dist/gateway/progress-card.js +52 -25
- package/dist/gateway/progress-card.js.map +1 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.js +277 -44
- package/dist/index.js.map +1 -1
- package/dist/memory-installer.d.ts +138 -0
- package/dist/memory-installer.js +908 -0
- package/dist/memory-installer.js.map +1 -0
- package/dist/memory.d.ts +79 -0
- package/dist/memory.js +182 -0
- package/dist/memory.js.map +1 -0
- package/dist/security/audit.d.ts +43 -0
- package/dist/security/audit.js +46 -0
- package/dist/security/audit.js.map +1 -0
- package/dist/security/paths.d.ts +41 -0
- package/dist/security/paths.js +240 -0
- package/dist/security/paths.js.map +1 -0
- package/dist/security/redact.d.ts +59 -0
- package/dist/security/redact.js +151 -0
- package/dist/security/redact.js.map +1 -0
- package/dist/session/session-store.d.ts +15 -0
- package/dist/session/session-store.js +16 -0
- package/dist/session/session-store.js.map +1 -1
- package/dist/types.d.ts +82 -6
- package/dist/web/config-api.js +32 -0
- package/dist/web/config-api.js.map +1 -1
- package/dist/web/memory-api.d.ts +25 -0
- package/dist/web/memory-api.js +186 -0
- package/dist/web/memory-api.js.map +1 -0
- package/dist/web/server.js +73 -2
- package/dist/web/server.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { basename, join, relative, resolve } from 'node:path';
|
|
5
|
-
import { CONFIG_DIR, CONFIG_PATH, DEFAULT_CONTEXT_REMIND_TOKENS, loadConfig, sameApps } from './config.js';
|
|
5
|
+
import { CONFIG_DIR, CONFIG_PATH, DEFAULT_CONTEXT_REMIND_TOKENS, INBOX_DIR, loadConfig, sameApps } from './config.js';
|
|
6
6
|
import { DEFAULT_CLAUDE_DIR, initManagedClaudeDir, resolveClaudeDir } from './claude-config.js';
|
|
7
7
|
import { buildTaskEnv } from './task-env.js';
|
|
8
8
|
import { warnIfNoClaudeAuth } from './auth-precheck.js';
|
|
@@ -12,17 +12,19 @@ import { clearPidFile, writePidFile, stopExistingBridgeAndWait } from './web/lif
|
|
|
12
12
|
import { AccessControl } from './access/access-control.js';
|
|
13
13
|
import { FeishuGateway } from './gateway/feishu-gateway.js';
|
|
14
14
|
import { ProgressCard, feishuErrorBody } from './gateway/progress-card.js';
|
|
15
|
-
import { DECISION_TEXT, buildResultTailCard, buildWorkspaceSwitchedCard, resultChunkHeader, } from './gateway/card-builder.js';
|
|
15
|
+
import { DECISION_TEXT, buildPlanTailCard, buildResultTailCard, buildWorkspaceSwitchedCard, planChunkHeader, resultChunkHeader, } from './gateway/card-builder.js';
|
|
16
16
|
import { chunkText } from './util/chunk-text.js';
|
|
17
|
-
import { runTask } from './executor/claude-executor.js';
|
|
17
|
+
import { runTask, killResidentSession, killAllResidentSessions } from './executor/claude-executor.js';
|
|
18
18
|
import { buildFileDiffCards } from './gateway/diff-card.js';
|
|
19
19
|
import { collectFileDiff } from './util/workspace-diff.js';
|
|
20
20
|
import { PermissionGate, DEFAULT_ALLOW_TOOLS_LIST, DEFAULT_DANGEROUS_COMMANDS, DEFAULT_ALLOW_ALL_TOOLS, } from './executor/permission-gate.js';
|
|
21
21
|
import { discoverPlugins, resolvePluginPaths } from './executor/plugin-discovery.js';
|
|
22
|
-
import { createGatewaySender, createNotifyServer, NOTIFY_SERVER_NAME } from './executor/notify-server.js';
|
|
22
|
+
import { createGatewaySender, createNotifyServer, createNotifySenderSlot, NOTIFY_SERVER_NAME } from './executor/notify-server.js';
|
|
23
23
|
import { BRIDGE_MCP_JSON, readMcpServersFromJsonFile } from './web/skills-mcp-api.js';
|
|
24
24
|
import { FEISHU_NOTIFY_SOP_PROMPT, TOOL_HINT_PROMPT } from './notify-sop.js';
|
|
25
25
|
import { ensureProfileSettingsFile, resolveAppProfile, resolveAppProfileModel } from './app-profile.js';
|
|
26
|
+
import { MEMORY_PLUGIN_NAME, createMemoryProvider, ensureMemoryUserKeys, memoryEnvFor, } from './memory.js';
|
|
27
|
+
import { stopManagedServer } from './memory-installer.js';
|
|
26
28
|
import { isImageFile } from './util/file-types.js';
|
|
27
29
|
import { FileTracker } from './util/file-tracker.js';
|
|
28
30
|
import { SessionStore, migrateLegacySessions } from './session/session-store.js';
|
|
@@ -31,6 +33,9 @@ import { isLocalCommandRewrite, matchTrigger, rewriteByTrigger } from './session
|
|
|
31
33
|
import { Semaphore, channelKey } from './session/channel.js';
|
|
32
34
|
import { TranscriptWriter, sweepTranscripts } from './transcript/transcript-writer.js';
|
|
33
35
|
import { nowBeijingISO } from './util/beijing-time.js';
|
|
36
|
+
import { getRedactor } from './security/redact.js';
|
|
37
|
+
import { DEFAULT_REDZONE_READ_SOURCES } from './security/paths.js';
|
|
38
|
+
import { CANARY_ALERT_TEXT, SecurityAudit } from './security/audit.js';
|
|
34
39
|
// 版本号收敛到 version.ts 单一来源(bin/lcb.ts 与 smoke 测试均引用)
|
|
35
40
|
export { VERSION } from './version.js';
|
|
36
41
|
// 任务总超时 4 小时(防挂死;确认等待、长编译均在正常范围)
|
|
@@ -58,7 +63,7 @@ const PLAN_DECISION_TEXT = {
|
|
|
58
63
|
'plan-approve': '✅ 计划已批准,开始执行(后续写操作免确认)',
|
|
59
64
|
'plan-revise': '📝 修改意见已提交',
|
|
60
65
|
'plan-reject': '❌ 计划已放弃',
|
|
61
|
-
// plan-view-file 不消费挂起项,仅 toast
|
|
66
|
+
// plan-view-file 不消费挂起项,仅 toast「正在重新发送计划内容卡片…」——文案不在此出现,留口供编译期完整性
|
|
62
67
|
'plan-view-file': '',
|
|
63
68
|
};
|
|
64
69
|
/**
|
|
@@ -130,6 +135,23 @@ deps, opts = {}) {
|
|
|
130
135
|
// plan 确认挂起项:requestId → 等待中的计划决策(plan-approve/revise/reject)。
|
|
131
136
|
// 嵌入主卡片后回调侧无 cardId(主卡 messageId 由 ProgressCard 内部管理),改带 channelKey 定位活跃进度卡
|
|
132
137
|
const planPending = new Map();
|
|
138
|
+
// 计划正文两段式多卡直发(与下方「结果回传两段式」完全同构):~3000 字/块,非尾卡走文本
|
|
139
|
+
// 消息、尾卡走卡片,连发间隔 300ms 防飞书限流。planAsk 与 plan-view-file 回调(存量卡片
|
|
140
|
+
// 残留按钮,重发即「再看一遍」)共用
|
|
141
|
+
async function sendPlanContentCards(chatId, req) {
|
|
142
|
+
const chunks = chunkText(req.plan);
|
|
143
|
+
const total = chunks.length;
|
|
144
|
+
for (let i = 0; i < total; i++) {
|
|
145
|
+
if (i > 0)
|
|
146
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
147
|
+
if (i === total - 1) {
|
|
148
|
+
await deps.gateway.sendCardTo(chatId, buildPlanTailCard(req, chunks[i], { index: total, total }, config.card?.width ?? 'default'));
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
await deps.gateway.sendTextTo(chatId, `${planChunkHeader(req, i + 1, total)}\n\n${chunks[i]}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
133
155
|
// 提问挂起项:requestId → 等待中的问题答案(qa-pick 逐项收集,qa-submit 一次性 resolve)。
|
|
134
156
|
// 嵌入主卡片后无 cardId,改带 channelKey 定位活跃进度卡
|
|
135
157
|
const qaPending = new Map();
|
|
@@ -144,6 +166,10 @@ deps, opts = {}) {
|
|
|
144
166
|
// 当前存活 query 的 MCP 状态拉取句柄(executor onQuery 注入、任务 finally 删除):
|
|
145
167
|
// /mcp 命令在任务运行中经此实时拉取;句柄随 query 结束失效,调用侧须 catch
|
|
146
168
|
const activeQueries = new Map();
|
|
169
|
+
// 通道级 notify sender 槽(生命周期方案 A2):常驻进程复用 spawn 时的 lcb-notify server
|
|
170
|
+
// 实例,发送目标(chatId 绑定 + sentPaths)经槽每任务换绑——同通道 FIFO 串行保证换绑
|
|
171
|
+
// 总在上一轮发送完成后发生
|
|
172
|
+
const notifySlots = new Map();
|
|
147
173
|
// 触发词两段式(ask_detail)等待补充的挂起项:channelKey → 命中规则与原始消息。
|
|
148
174
|
// 命中「需补充」规则时先追问不入队;同用户下一条非 / 消息视作补充内容,与 rewrite 合并后入队。
|
|
149
175
|
// 超时(SUPPLEMENT_WAIT_MS)自动失效走原流程;任意本地命令(cmd.handled)即取消等待——
|
|
@@ -322,9 +348,10 @@ deps, opts = {}) {
|
|
|
322
348
|
// currentWorkspace 语义:store 中已设置的工作区优先,否则 app 默认,最后全局默认——
|
|
323
349
|
// 保证 /new、/status 回复与实际一致。用 || 而非 ??:archiveSession 为新通道建档时
|
|
324
350
|
// workspaceName 存 ''(归档时不知工作区),空串必须回退默认,否则 inventory 键错位。
|
|
325
|
-
//
|
|
326
|
-
// wsName 同源锁定,通道 state 旧存的 workspaceName
|
|
327
|
-
|
|
351
|
+
// 分身角色配置了工作区白名单时恒锁首个允许的工作区:命令上下文、inventory 键与任务
|
|
352
|
+
// wsName 同源锁定,通道 state 旧存的 workspaceName 一律不采信;未配置白名单 =
|
|
353
|
+
// 权限轴「全给」,与主机器人同规则(state 优先 → app 默认 → 全局默认)
|
|
354
|
+
const currentWorkspace = app.role === 'deputy' && app.allowedWorkspaces?.length
|
|
328
355
|
? app.allowedWorkspaces[0]
|
|
329
356
|
: (st?.workspaceName || app.defaultWorkspace || config.defaults.workspace);
|
|
330
357
|
// 2.4 触发词两段式补充(ask_detail):上一条消息命中「需补充」规则且正在等待时,本条
|
|
@@ -436,11 +463,14 @@ deps, opts = {}) {
|
|
|
436
463
|
await deps.gateway.sendTextTo(msg.chatId, cmd.reply);
|
|
437
464
|
}
|
|
438
465
|
// 指针变更代际 bump:/new、/resume <n>、/ws use 都改写了 currentSessionId——
|
|
439
|
-
// 运行中/收尾窗口内任务的迟到归档据 pointerGen 放弃回写指针(archive
|
|
466
|
+
// 运行中/收尾窗口内任务的迟到归档据 pointerGen 放弃回写指针(archive 闭包比对)。
|
|
467
|
+
// 同时作废该通道的常驻进程:换了续接目标,旧进程的会话上下文不再适用(忙进程不动,
|
|
468
|
+
// 运行中任务的中止由 /new 的 abort 负责,abort 会连带杀进程)
|
|
440
469
|
if (cmd.pointerTouched) {
|
|
441
470
|
const rt = runtimes.get(key);
|
|
442
471
|
if (rt)
|
|
443
472
|
rt.pointerGen = (rt.pointerGen ?? 0) + 1;
|
|
473
|
+
killResidentSession(`${app.appId}:${key}`);
|
|
444
474
|
}
|
|
445
475
|
// /new 开启新会话:
|
|
446
476
|
// 1) 清空旧代际排队消息(epoch 自增,旧任务入 executeTask 即 no-op)
|
|
@@ -534,6 +564,30 @@ deps, opts = {}) {
|
|
|
534
564
|
// 白名单/黑名单经 getter 传入:gate 虽按通道永久复用,decide 时才经 getter 现读
|
|
535
565
|
// config.permissions——热重载原地 mutate 同一 config 对象后,已有通道的下一个工具
|
|
536
566
|
// 调用即生效(不再需要新通道/重启;0.17.0 静态快照导致改配置后旧通道不生效)
|
|
567
|
+
// 分身安全闸(R1/R2/Bash 三态):仅 deputy 注入,主机器人行为零变化
|
|
568
|
+
const securityGuard = app.role === 'deputy'
|
|
569
|
+
? {
|
|
570
|
+
cwd: workspacePath(wsName),
|
|
571
|
+
// 入站附件豁免根:与 gateway 下载目录共用单源常量(config.ts INBOX_DIR)——用户发给 bot 的
|
|
572
|
+
// 图片/文件必须可读可处理,属产品功能正确性;R1/Bash 探测豁免,R2 写边界不豁免
|
|
573
|
+
inboxRoot: INBOX_DIR,
|
|
574
|
+
redzone: () => config.security?.redzone?.read ?? DEFAULT_REDZONE_READ_SOURCES,
|
|
575
|
+
// 未配置工作区白名单 = 权限轴全给:写根 = 全部已配置工作区(R2 红线仍在:工作区外只读)
|
|
576
|
+
writeRoots: () => (app.allowedWorkspaces?.length ? app.allowedWorkspaces : config.workspaces.map((w) => w.name))
|
|
577
|
+
.map((n) => workspacePath(n)),
|
|
578
|
+
bashMode: () => app.bashMode ?? 'confirm',
|
|
579
|
+
}
|
|
580
|
+
: undefined;
|
|
581
|
+
// 安全事件分流:审计落盘(always)+ 金丝雀向当前聊天告警(固定模板,节流在 gateway 侧)
|
|
582
|
+
const onSecurityEvent = (ev) => {
|
|
583
|
+
deps.audit?.write(app.appId, ev);
|
|
584
|
+
if (ev.kind !== 'canary') {
|
|
585
|
+
console.warn(tag, '[安全拦截]', JSON.stringify(ev));
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
console.error(tag, '[安全告警] 🚨 金丝雀命中(防线疑似被穿透),已脱敏并告警');
|
|
589
|
+
void deps.gateway.sendTextTo(msg.chatId, CANARY_ALERT_TEXT).catch(() => { });
|
|
590
|
+
};
|
|
537
591
|
let gate = gates.get(key);
|
|
538
592
|
if (!gate) {
|
|
539
593
|
gate = new PermissionGate({
|
|
@@ -541,6 +595,8 @@ deps, opts = {}) {
|
|
|
541
595
|
// ?? 而非 ||:显式 false(用户主动关闭全部免确认)不能被回落到缺省的 true
|
|
542
596
|
allowAllTools: () => config.permissions?.allowAllTools ?? DEFAULT_ALLOW_ALL_TOOLS,
|
|
543
597
|
dangerousCommands: () => config.permissions?.dangerousCommands ?? DEFAULT_DANGEROUS_COMMANDS,
|
|
598
|
+
...(securityGuard ? { security: securityGuard } : {}),
|
|
599
|
+
onSecurityEvent,
|
|
544
600
|
ask: async (req) => {
|
|
545
601
|
// 工具确认嵌入当前任务的进度卡(按钮在计时行上方、正文收敛)——不再单独发确认卡:
|
|
546
602
|
// 独立确认卡与进度卡正文内容大量重复,且追加在进度卡下方会把进度卡顶出会话底部。
|
|
@@ -576,19 +632,24 @@ deps, opts = {}) {
|
|
|
576
632
|
});
|
|
577
633
|
});
|
|
578
634
|
},
|
|
579
|
-
// plan 确认(#3+#6
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
// 仍在主卡片的内嵌 form 内。超时由本闭包自管:到点 clearPlan + resolve reject
|
|
635
|
+
// plan 确认(#3+#6):计划正文与完整回复同款两段式多卡直发(sendPlanContentCards),
|
|
636
|
+
// 不搞「点按钮收文件」特例;原文另落盘 claude/plans/ 归档(slug + 时间戳,方便 review
|
|
637
|
+
// 与 SOP 要求的本地归档,不再有回调消费)。批准/放弃/按意见修改三按钮与意见输入框仍在
|
|
638
|
+
// 主卡片的内嵌 form 内。超时由本闭包自管:到点 clearPlan + resolve reject
|
|
584
639
|
planAsk: async (req) => {
|
|
585
640
|
const planReq = { requestId: randomUUID(), plan: req.plan, workspaceName: req.workspaceName };
|
|
586
|
-
//
|
|
587
|
-
const planFilePath = writeRuntimeMarkdown(join('claude', 'plans'), req.workspaceName || 'plan', `# 执行计划 · ${req.workspaceName} · ${new Date().toLocaleString('zh-CN', { hour12: false })}\n\n${req.plan}\n`, `${tag}[
|
|
641
|
+
// 落盘归档:文件名加时间戳避免并发冲突;失败仅影响归档,不影响直发与审批
|
|
642
|
+
const planFilePath = writeRuntimeMarkdown(join('claude', 'plans'), req.workspaceName || 'plan', `# 执行计划 · ${req.workspaceName} · ${new Date().toLocaleString('zh-CN', { hour12: false })}\n\n${req.plan}\n`, `${tag}[计划归档落盘失败](不影响流程):`);
|
|
643
|
+
if (planFilePath)
|
|
644
|
+
console.log(tag, `[计划归档] ${planFilePath}`);
|
|
588
645
|
const progress = activeProgress.get(key);
|
|
589
646
|
if (!progress)
|
|
590
647
|
return { action: 'reject' }; // 进度卡不在 = 任务已结束/被 abort,按放弃处理
|
|
591
|
-
progress.setPlan({ requestId: planReq.requestId, plan: req.plan, workspaceName: req.workspaceName
|
|
648
|
+
progress.setPlan({ requestId: planReq.requestId, plan: req.plan, workspaceName: req.workspaceName });
|
|
649
|
+
// 正文直发放挂起开始前:raceFallback 兜底 WS 偶发挂起(此刻超时定时器尚未 arm,裸 await
|
|
650
|
+
// 挂起即永不超时——同 ask 发卡约束);raceFallback 永不 reject,失败/超时仅内部落日志,
|
|
651
|
+
// 审批表单照常可用
|
|
652
|
+
await raceFallback(sendPlanContentCards(msg.chatId, planReq), CARD_SEND_TIMEOUT_MS, undefined);
|
|
592
653
|
// 不再 sinkToBottom:plan 区嵌入主进度卡,flush 自然 PATCH,删旧卡重发只会让用户看到计划区跳一下
|
|
593
654
|
return new Promise((resolve) => {
|
|
594
655
|
const gc = setTimeout(() => {
|
|
@@ -607,7 +668,6 @@ deps, opts = {}) {
|
|
|
607
668
|
ownerId: msg.userId,
|
|
608
669
|
channelKey: key,
|
|
609
670
|
chatId: msg.chatId,
|
|
610
|
-
planFilePath,
|
|
611
671
|
});
|
|
612
672
|
});
|
|
613
673
|
},
|
|
@@ -663,7 +723,14 @@ deps, opts = {}) {
|
|
|
663
723
|
const toolHintEnabled = config.session?.toolHint !== false;
|
|
664
724
|
const sentPaths = new Set();
|
|
665
725
|
const sink = async () => { await activeProgress.get(key)?.sinkToBottom(); };
|
|
666
|
-
|
|
726
|
+
// 槽式换绑(A2):本任务的 sender(chatId 硬绑定 + sentPaths 去重集)挂进通道级槽——
|
|
727
|
+
// 常驻进程复用 spawn 时的 lcb-notify server 实例,经 delegate 路由到槽内当前 sender
|
|
728
|
+
let notifySlot = notifySlots.get(key);
|
|
729
|
+
if (!notifySlot) {
|
|
730
|
+
notifySlot = createNotifySenderSlot();
|
|
731
|
+
notifySlots.set(key, notifySlot);
|
|
732
|
+
}
|
|
733
|
+
notifySlot.setSender(createGatewaySender({
|
|
667
734
|
chatId: msg.chatId,
|
|
668
735
|
sentPaths,
|
|
669
736
|
sendText: async (c, md) => {
|
|
@@ -682,7 +749,7 @@ deps, opts = {}) {
|
|
|
682
749
|
await deps.gateway.uploadAndSendFile(c, p);
|
|
683
750
|
await sink();
|
|
684
751
|
},
|
|
685
|
-
});
|
|
752
|
+
}));
|
|
686
753
|
activeProgress.set(key, progress);
|
|
687
754
|
// 指针代际快照:任务运行期间 /new、/resume <n>、/ws use 都会 bump pointerGen,本任务
|
|
688
755
|
// 收尾归档据此放弃回写指针(只入历史不覆盖用户选择)。定义在 try 外:catch 路径同样调用
|
|
@@ -697,10 +764,40 @@ deps, opts = {}) {
|
|
|
697
764
|
try {
|
|
698
765
|
await progress.start();
|
|
699
766
|
const state = deps.store.getChannelState(key);
|
|
767
|
+
// A3 群聊会话自动滚动(双阈值,先到先滚,业务无感):空闲超阈值(话题切换天然伴随
|
|
768
|
+
// 时间间隔)或轮次满阈值(兜底单话题超长)→ 指针置 null——即 /new 的现成一行语义,
|
|
769
|
+
// 本条消息就是新话题的首条。私聊不滚(手动 /new 管理最优)。滚动同样 bump epoch
|
|
770
|
+
// 对齐 /new 语义:旧代际排队消息按「新建会话」丢弃(指针写保护已有 pointerGen 兜底)。
|
|
771
|
+
// 有排队消息被丢弃时显式告知(静默吞用户消息不可接受);滚动本身不发独立提示,
|
|
772
|
+
// 仅在本条回复尾部带一次轻注记(见下方成功路径,可配置关闭)
|
|
773
|
+
let rolled = false;
|
|
774
|
+
if (msg.chatType === 'group') {
|
|
775
|
+
const idleHours = config.session?.groupIdleResetHours ?? 4;
|
|
776
|
+
const maxTurns = config.session?.groupMaxTurns ?? 30;
|
|
777
|
+
const lastActiveMs = state?.lastActiveAt ? Date.parse(state.lastActiveAt) : NaN;
|
|
778
|
+
const idleHit = idleHours > 0 && Number.isFinite(lastActiveMs) && Date.now() - lastActiveMs > idleHours * 3_600_000;
|
|
779
|
+
const turnsHit = maxTurns > 0 && (state?.turnCount ?? 0) >= maxTurns;
|
|
780
|
+
if (idleHit || turnsHit) {
|
|
781
|
+
deps.store.setCurrentSession(key, null, state?.workspaceName || wsName); // turnCount 随之清零
|
|
782
|
+
const rtRoll = runtimes.get(key);
|
|
783
|
+
const dropped = rtRoll?.queuedCount ?? 0;
|
|
784
|
+
if (rtRoll) {
|
|
785
|
+
rtRoll.epoch = (rtRoll.epoch ?? 0) + 1; // 本条已过代际检查,继续执行;后续旧代际排队消息入口 no-op
|
|
786
|
+
rtRoll.queuedCount = 0;
|
|
787
|
+
}
|
|
788
|
+
rolled = true;
|
|
789
|
+
console.log(tag, `[滚动] ${idleHit ? `空闲超 ${idleHours}h` : `轮次满 ${maxTurns}`},群聊自动开启新话题:${key}`);
|
|
790
|
+
if (dropped > 0) {
|
|
791
|
+
await deps.gateway.sendTextTo(msg.chatId, `🗑️ 已开启新话题,${dropped} 条排队消息按旧话题取消(/resume 可找回此前讨论)`)
|
|
792
|
+
.catch(() => { });
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
700
796
|
// 续接指针三态:undefined=旧版存量数据 → 回退 sessions[0](旧语义 sessions[0] 即当前);
|
|
701
797
|
// null=/new 或 /ws use 已清指针 → 新开会话;字符串 → resume 该会话。
|
|
702
|
-
// 不能用 ??:null 会被 ?? 当成空值回退 sessions[0],/new
|
|
703
|
-
|
|
798
|
+
// 不能用 ??:null 会被 ?? 当成空值回退 sessions[0],/new 的意图就被吞掉了。
|
|
799
|
+
// 滚动刚把指针置 null:本条直接开新会话(rolled 分支不用再读盘)
|
|
800
|
+
const pointer = rolled ? null : state?.currentSessionId;
|
|
704
801
|
const resumeId = pointer === undefined ? state?.sessions?.[0]?.sessionId : pointer || undefined;
|
|
705
802
|
const now = () => nowBeijingISO();
|
|
706
803
|
// 落盘 · user:任务起点(prompt 全文 + resume 链上一轮 sessionId)
|
|
@@ -710,6 +807,15 @@ deps, opts = {}) {
|
|
|
710
807
|
});
|
|
711
808
|
// 任务级 env 现读构造(热生效):apps[].env / claude.env 改动后下一条消息即用新值
|
|
712
809
|
const taskEnv = buildTaskEnv(config, app, claudeDir);
|
|
810
|
+
// 长期记忆 ns env(§6.4/§6.6):memory.enabled 时每条消息拼装下发——OPENVIKING_USER 按
|
|
811
|
+
// 消息级 userId 隔离(群聊同事话语不混入主人记忆;deputy 的 appId 天然不同即分身永不读
|
|
812
|
+
// 主人记忆)。?? 保留既有值:apps[].env 手配 OPENVIKING_* = 专家覆盖口。关段 = 零注入,
|
|
813
|
+
// 行为与无此段完全一致(可插拔验收);常驻进程经配置指纹回收(env 变 → 指纹变 → 重建)
|
|
814
|
+
if (config.memory?.enabled) {
|
|
815
|
+
for (const [k, v] of Object.entries(memoryEnvFor(config.memory, app.appId, msg.userId, deps.memory?.userKeys()))) {
|
|
816
|
+
taskEnv[k] ??= v;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
713
819
|
// 打点只列键名不列值(env 常含凭证,防日志泄漏);值含 ${ 提示误配——env 值不做变量
|
|
714
820
|
// 展开,${VAR} 会原样传给 CLI(变量展开只在 MCP 配置一侧,展开源正是这里注入的环境)
|
|
715
821
|
const envKeys = [...new Set([...Object.keys(app.env ?? {}), ...Object.keys(config.claude?.env ?? {})])];
|
|
@@ -766,28 +872,64 @@ deps, opts = {}) {
|
|
|
766
872
|
// 机器人档案认证 settings 文件(--settings):仅含认证键,命令行层压制生效目录 env
|
|
767
873
|
...(profileSettingsPath ? { extraArgs: { settings: profileSettingsPath } } : {}),
|
|
768
874
|
// 进程内通知工具(send_text/send_image/send_file):中间产物实时推给当前聊天。
|
|
769
|
-
// server
|
|
875
|
+
// server 实例按任务构造(挂通道槽 delegate,见上方槽式换绑),chatId 在 sender
|
|
876
|
+
// 闭包内硬绑定——模型无法选择接收者。
|
|
770
877
|
// 配置页 MCP(#12)一并注入:bridge 自管 servers.json 每任务现读(热生效),notify 后展开保名
|
|
771
|
-
mcpServers: { ...loadBridgeMcpServers(), [NOTIFY_SERVER_NAME]: createNotifyServer(
|
|
878
|
+
mcpServers: { ...loadBridgeMcpServers(), [NOTIFY_SERVER_NAME]: createNotifyServer(notifySlot.delegate) },
|
|
879
|
+
// A2 常驻进程池:同通道亲和复用 CLI 子进程(冷启动税摊薄为每会话一次)。
|
|
880
|
+
// 指纹 = 本任务现读的全部 spawn 输入(人设/模型/plan/env/MCP/插件/档案 settings/
|
|
881
|
+
// cwd/续接指针),任一变化 → executor 回收重建,保「每任务现读热生效」语义;
|
|
882
|
+
// 空闲回收与池上限策略同样每任务现读(热改配置下一条消息生效)
|
|
883
|
+
affinityKey: `${app.appId}:${key}`,
|
|
884
|
+
resident: config.session?.residentProcess !== false,
|
|
885
|
+
residentIdleMs: (config.session?.residentIdleMinutes ?? 60) * 60_000,
|
|
886
|
+
residentMax: config.session?.residentMaxProcesses ?? (app.concurrency ?? config.concurrency),
|
|
887
|
+
residentScope: app.appId,
|
|
772
888
|
// 插件:config.yaml 显式配置(开发期指源码目录)+ Claude 配置目录已启用的 marketplace
|
|
773
889
|
// 插件自动发现,同名显式优先;SDK 对无效路径静默跳过,实际加载以 init 清单(/plugins 命令)为准。
|
|
774
890
|
// managed 模式合并发现两处目录(自管 + 本机 ~/.claude):按绝对 installPath 传 SDK,
|
|
775
891
|
// 用户本机已装插件无须重装;resolvePluginPaths 按 name 去重,当前生效目录优先。
|
|
776
892
|
// 分身角色按 allowed_plugins 白名单过滤(显式 app.plugins 始终保留——写在分身配置里
|
|
777
|
-
// 的就是要给的);未配 allowed_plugins
|
|
893
|
+
// 的就是要给的);未配 allowed_plugins = 权限轴「全给」,自动发现插件全部加载。
|
|
894
|
+
// 记忆插件自动注入(§6.9):memory.enabled 时 openviking-memory 以宿主基础设施身份
|
|
895
|
+
// 加入加载集,不受 deputy 白名单过滤——记忆是宿主给每个 bot 的恒定能力(同 notify
|
|
896
|
+
// server),不是主人分配给分身的业务插件。B0 实测教训:不注入则白名单分身静默金鱼记忆。
|
|
778
897
|
...(() => {
|
|
779
898
|
const discovered = claudeDir === DEFAULT_CLAUDE_DIR
|
|
780
899
|
? discoverPlugins(claudeDir)
|
|
781
900
|
: [...discoverPlugins(claudeDir), ...discoverPlugins(DEFAULT_CLAUDE_DIR)];
|
|
782
901
|
let merged = resolvePluginPaths(app.plugins, discovered);
|
|
783
|
-
if (app.role === 'deputy') {
|
|
784
|
-
merged = merged.filter((p) => app.plugins?.some((e) => e.name === p.name) || app.allowedPlugins
|
|
902
|
+
if (app.role === 'deputy' && app.allowedPlugins !== undefined) {
|
|
903
|
+
merged = merged.filter((p) => app.plugins?.some((e) => e.name === p.name) || app.allowedPlugins.includes(p.name));
|
|
904
|
+
}
|
|
905
|
+
// 装配校验告警 ①(§6.9 L2):deputy 白名单零命中——配了白名单但与发现集零交集,
|
|
906
|
+
// 「以为在收权,实际连一个插件都没给」几乎必为配置错误(B0 实测:二号 bot 配
|
|
907
|
+
// [library] 而该插件未启用 → 实际加载 0 插件,全程零提示)。在记忆注入前判定,
|
|
908
|
+
// 避免注入的记忆插件掩盖零命中事实
|
|
909
|
+
if (app.role === 'deputy' && app.allowedPlugins?.length && merged.length === 0) {
|
|
910
|
+
console.warn(tag, `[memory] 分身 "${app.name}" 配置了 allowed_plugins(${app.allowedPlugins.join('、')})`
|
|
911
|
+
+ ' 但与已发现插件零命中:自动发现插件实际加载 0 个。请检查插件名拼写,或确认插件已安装且在 '
|
|
912
|
+
+ 'settings.json enabledPlugins 中启用(/plugins 可查实际加载清单;方案 §6.9)');
|
|
913
|
+
}
|
|
914
|
+
// 记忆插件注入(§6.9 L1):幂等合流——已显式配置或已过白名单时同名去重不重复加载;
|
|
915
|
+
// 未发现(未安装/未启用)则注入无从谈起,落告警 ② 提示安装,不静默
|
|
916
|
+
if (config.memory?.enabled && !merged.some((p) => p.name === MEMORY_PLUGIN_NAME)) {
|
|
917
|
+
const found = discovered.find((p) => p.name === MEMORY_PLUGIN_NAME);
|
|
918
|
+
if (found)
|
|
919
|
+
merged.push({ name: found.name, path: found.path, source: 'discovered' });
|
|
920
|
+
else {
|
|
921
|
+
console.warn(tag, `[memory] memory.enabled=true 但插件 ${MEMORY_PLUGIN_NAME} 未被发现(未安装或 enabledPlugins 未启用):`
|
|
922
|
+
+ '记忆召回/沉淀 hooks 不会生效。安装:claude plugin install openviking-memory@openviking'
|
|
923
|
+
+ '(或经配置页插件安装;详见 docs 方案 §6.9)');
|
|
924
|
+
}
|
|
785
925
|
}
|
|
786
926
|
return merged.length ? { plugins: merged.map((p) => ({ path: p.path })) } : {};
|
|
787
927
|
})(),
|
|
788
928
|
// 分身技能白名单:透传 SDK Options.skills(官方上下文过滤器——未列出技能对模型不可见
|
|
789
929
|
// 且被 Skill 工具拒绝;非沙箱:技能文件仍在磁盘可被 Read/Bash 读到,用户已知悉此边界)
|
|
790
930
|
...(app.role === 'deputy' && app.allowedSkills?.length ? { allowedSkills: app.allowedSkills } : {}),
|
|
931
|
+
// 分身计划批准不升级 acceptEdits:该模式会绕过 canUseTool,宿主安全闸(R1/R2)随之失效
|
|
932
|
+
...(app.role === 'deputy' ? { planApproveEscalates: false } : {}),
|
|
791
933
|
canUseTool: (toolName, input, ctx) => gate.decide(toolName, input, wsName, ctx),
|
|
792
934
|
onQuery: (handle) => { activeQueries.set(key, { wsName, handle }); },
|
|
793
935
|
}, {
|
|
@@ -818,20 +960,30 @@ deps, opts = {}) {
|
|
|
818
960
|
}
|
|
819
961
|
else if (e.kind === 'text') {
|
|
820
962
|
progress.markStarted();
|
|
821
|
-
|
|
963
|
+
// R3:流式正文块脱敏后再渲染/落盘(进度卡展示 + transcript 双消费点;
|
|
964
|
+
// gateway 深度脱敏仍在其后兜底,双保险覆盖 transcript 这一本地落盘面)
|
|
965
|
+
const streamRedaction = getRedactor(config.security).text(e.content);
|
|
966
|
+
if (streamRedaction.hits.length) {
|
|
967
|
+
deps.audit?.write(app.appId, { kind: 'redact', ruleIds: [...new Set(streamRedaction.hits)], source: 'stream' });
|
|
968
|
+
}
|
|
969
|
+
if (streamRedaction.canary)
|
|
970
|
+
onSecurityEvent({ kind: 'canary', source: 'stream' });
|
|
971
|
+
progress.appendText(streamRedaction.text);
|
|
822
972
|
// 落盘 · assistant:流式文本块全文(每块一行,天然增量)
|
|
823
973
|
deps.transcript?.assistant({
|
|
824
974
|
v: 1, ts: now(), kind: 'assistant', app: app.appId,
|
|
825
|
-
chatId: msg.chatId, userId: msg.userId, text:
|
|
975
|
+
chatId: msg.chatId, userId: msg.userId, text: streamRedaction.text,
|
|
826
976
|
});
|
|
827
977
|
}
|
|
828
978
|
else if (e.kind === 'tool-start') {
|
|
829
979
|
const [n, ...rest] = e.content.split(': ');
|
|
830
980
|
progress.markStarted();
|
|
831
|
-
|
|
981
|
+
// 摘要(命令串/路径)同样过脱敏再落盘——命令回显可能带出先前输出中的秘密
|
|
982
|
+
const summaryRedaction = getRedactor(config.security).text(rest.join(': '));
|
|
983
|
+
progress.toolStart(n, summaryRedaction.text);
|
|
832
984
|
deps.transcript?.tool({
|
|
833
985
|
v: 1, ts: now(), kind: 'tool', app: app.appId,
|
|
834
|
-
chatId: msg.chatId, userId: msg.userId, phase: 'start', tool: n, summary:
|
|
986
|
+
chatId: msg.chatId, userId: msg.userId, phase: 'start', tool: n, summary: summaryRedaction.text, ok: true,
|
|
835
987
|
});
|
|
836
988
|
}
|
|
837
989
|
else if (e.kind === 'tool-result') {
|
|
@@ -861,8 +1013,25 @@ deps, opts = {}) {
|
|
|
861
1013
|
});
|
|
862
1014
|
return;
|
|
863
1015
|
}
|
|
864
|
-
// finalText 提前算好:finish
|
|
865
|
-
|
|
1016
|
+
// finalText 提前算好:finish 携带字数,终态进度卡据此显示「完整回复已单独发送」提示行。
|
|
1017
|
+
// R3:先脱敏再落盘/发送——transcript 写入(下方 result 事件)与结果卡都消费脱敏后文本,
|
|
1018
|
+
// bridge 自己的落盘不再残留明文(方案 v3 §八.4:脱敏点在 transcript 之前,非发送点)
|
|
1019
|
+
const finalRedaction = getRedactor(config.security).text(outcome.finalText?.trim() ?? '');
|
|
1020
|
+
if (finalRedaction.hits.length) {
|
|
1021
|
+
deps.audit?.write(app.appId, { kind: 'redact', ruleIds: [...new Set(finalRedaction.hits)], source: 'final' });
|
|
1022
|
+
}
|
|
1023
|
+
if (finalRedaction.canary)
|
|
1024
|
+
onSecurityEvent({ kind: 'canary', source: 'final' });
|
|
1025
|
+
const finalText = finalRedaction.text;
|
|
1026
|
+
// A3 滚动轻注记:新话题首条回复尾部带一次(仅此一条,可配置关闭)——不发独立提示
|
|
1027
|
+
// 消息(群聊里机器人自言自语就是噪音),失忆困惑由注记 + /resume 兜底;记忆层上线后
|
|
1028
|
+
// 此注记可弱化/去除。注记只进回传文本,不进 transcript(传输层装饰,非模型产出)
|
|
1029
|
+
const rollNote = rolled && config.session?.groupRollNotify !== false
|
|
1030
|
+
? '🍃 已开启新话题(此前讨论 /resume 可找回)'
|
|
1031
|
+
: '';
|
|
1032
|
+
const replyText = finalText
|
|
1033
|
+
? (rollNote ? `${finalText}\n\n---\n${rollNote}` : finalText)
|
|
1034
|
+
: rollNote;
|
|
866
1035
|
await progress.finish('✅ 完成', finalText.length || undefined);
|
|
867
1036
|
archive(outcome.sessionId);
|
|
868
1037
|
deps.transcript?.result({
|
|
@@ -886,8 +1055,8 @@ deps, opts = {}) {
|
|
|
886
1055
|
// 消息限流)——不论长短都不再进进度卡正文(主卡只留提示行),彻底消除「折叠看不到全文」
|
|
887
1056
|
// 与「卡片/消息两边重复」(旧三段式 ≤400 终态自载的边界问题)。尾卡纯展示,想继续对话/
|
|
888
1057
|
// 提意见直接回复消息即可
|
|
889
|
-
if (
|
|
890
|
-
const chunks = chunkText(
|
|
1058
|
+
if (replyText) {
|
|
1059
|
+
const chunks = chunkText(replyText);
|
|
891
1060
|
const total = chunks.length;
|
|
892
1061
|
const resultReq = {
|
|
893
1062
|
charCount: finalText.length, workspaceName: wsName,
|
|
@@ -980,6 +1149,9 @@ deps, opts = {}) {
|
|
|
980
1149
|
activeQueries.delete(key);
|
|
981
1150
|
rt.abort = undefined;
|
|
982
1151
|
rt.busy = false;
|
|
1152
|
+
// A3 滚动计数:任务收尾即一轮活跃(成功/出错/停止都消耗了上下文,均向阈值累积);
|
|
1153
|
+
// 指针与历史归档已在上文完成,markActivity 只记时间戳与轮次
|
|
1154
|
+
deps.store.markActivity(key);
|
|
983
1155
|
release();
|
|
984
1156
|
}
|
|
985
1157
|
}
|
|
@@ -999,12 +1171,12 @@ deps, opts = {}) {
|
|
|
999
1171
|
return { toast: { type: 'info', content: '仅任务发起人可操作计划' } };
|
|
1000
1172
|
}
|
|
1001
1173
|
const decision = action.value.decision;
|
|
1002
|
-
//
|
|
1174
|
+
// plan-view-file(存量卡片残留按钮):计划正文已改为卡片直发(planAsk),此处等价重发
|
|
1175
|
+
// 一遍内容卡片组——不消耗挂起项,可反复点击
|
|
1003
1176
|
if (decision === 'plan-view-file') {
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
return { toast: { type: 'info', content: '正在发送方案文件…' } };
|
|
1177
|
+
void sendPlanContentCards(plan.chatId, plan.req)
|
|
1178
|
+
.catch((e) => console.error('[计划内容重发失败]', action.value.requestId, e));
|
|
1179
|
+
return { toast: { type: 'info', content: '正在重新发送计划内容卡片…' } };
|
|
1008
1180
|
}
|
|
1009
1181
|
if (decision !== 'plan-approve' && decision !== 'plan-revise' && decision !== 'plan-reject') {
|
|
1010
1182
|
return { toast: { type: 'info', content: '该计划确认已被处理或已过期' } };
|
|
@@ -1125,10 +1297,12 @@ deps, opts = {}) {
|
|
|
1125
1297
|
return { toast: { type: 'info', content: '该工作区已是当前工作区' } };
|
|
1126
1298
|
}
|
|
1127
1299
|
applyWorkspaceSwitch(deps.store, wsc.channelKey, target);
|
|
1128
|
-
// 等价 cmd.pointerTouched
|
|
1300
|
+
// 等价 cmd.pointerTouched:切换清了续接指针,运行中/收尾窗口内任务的迟到归档不得回写;
|
|
1301
|
+
// 常驻进程同步作废(cwd 已随工作区变化,旧进程上下文不再适用)
|
|
1129
1302
|
const wsRt = runtimes.get(wsc.channelKey);
|
|
1130
1303
|
if (wsRt)
|
|
1131
1304
|
wsRt.pointerGen = (wsRt.pointerGen ?? 0) + 1;
|
|
1305
|
+
killResidentSession(`${app.appId}:${wsc.channelKey}`);
|
|
1132
1306
|
// 同通道其余工作区卡置 stale:旧卡显示的当前项已过时,继续可点会诱导误切回旧工作区
|
|
1133
1307
|
for (const [id, e] of wsPending) {
|
|
1134
1308
|
if (e.channelKey === wsc.channelKey && id !== action.value.requestId)
|
|
@@ -1199,7 +1373,7 @@ deps, opts = {}) {
|
|
|
1199
1373
|
* 读失败(文件被写坏的中间态等)沿用旧值不崩,并返回用户可读的错误消息(成功返回 null)——
|
|
1200
1374
|
* 调用方(processMessage)据此在聊天里显式提示「本次沿用旧配置」,不再静默。
|
|
1201
1375
|
*/
|
|
1202
|
-
export function createConfigReloader(config, configPath) {
|
|
1376
|
+
export function createConfigReloader(config, configPath, opts = {}) {
|
|
1203
1377
|
return () => {
|
|
1204
1378
|
let fresh;
|
|
1205
1379
|
try {
|
|
@@ -1232,8 +1406,10 @@ export function createConfigReloader(config, configPath) {
|
|
|
1232
1406
|
config.workspaces = fresh.workspaces;
|
|
1233
1407
|
config.defaults = fresh.defaults;
|
|
1234
1408
|
// 权限白名单/黑名单热应用:通道级 gate 复用,但经 getter 在 decide 时现读 config.permissions,
|
|
1235
|
-
//
|
|
1409
|
+
// 此处原地替换后已有通道的下一个工具调用即用新名单。安保段(红区/脱敏/金丝雀)同款 getter
|
|
1410
|
+
// 现读 → 热生效;脱敏器按配置键 memo,键变化自动重编译
|
|
1236
1411
|
config.permissions = fresh.permissions;
|
|
1412
|
+
config.security = fresh.security;
|
|
1237
1413
|
config.claude = fresh.claude;
|
|
1238
1414
|
config.server = fresh.server;
|
|
1239
1415
|
config.slashCommands = fresh.slashCommands;
|
|
@@ -1242,6 +1418,13 @@ export function createConfigReloader(config, configPath) {
|
|
|
1242
1418
|
// 卡片宽度热应用:gateway 的 cardWidth getter 与各处发卡传参都现读 config.card,
|
|
1243
1419
|
// 原地替换后下一条消息的所有卡片即用新宽度(0.x 遗漏导致改宽度须重启的修复)
|
|
1244
1420
|
config.card = fresh.card;
|
|
1421
|
+
// 长期记忆段热应用(§6.6):OPENVIKING_* env 下发与记忆插件注入均每任务现读 config.memory,
|
|
1422
|
+
// 原地替换后下一条消息即生效;常驻进程经配置指纹回收(env/plugins 变化 → 指纹变 → 重建)。
|
|
1423
|
+
// 段变化时通知调用方(startBridge 据此后台重签 user key——account/root key 改动会使旧 key 失配)
|
|
1424
|
+
const memoryChanged = JSON.stringify(fresh.memory ?? null) !== JSON.stringify(config.memory ?? null);
|
|
1425
|
+
config.memory = fresh.memory;
|
|
1426
|
+
if (memoryChanged)
|
|
1427
|
+
opts.onMemoryChanged?.(fresh.memory);
|
|
1245
1428
|
// managed 模式 claude.env 热生效:inherit 由每任务 buildTaskEnv 并入子进程 env 即可;
|
|
1246
1429
|
// managed 走托管 settings.json 通道(CLI 侧 settings.json env 优先于进程 env),
|
|
1247
1430
|
// 此处即时重写盘保证两通道同值——不写盘则手改 env 会被盘上旧值覆盖而静默失效
|
|
@@ -1264,6 +1447,8 @@ export function createConfigReloader(config, configPath) {
|
|
|
1264
1447
|
// 机器人级厂商档案热生效:executeTask 每任务经 app-profile.ts 现读解析
|
|
1265
1448
|
config.apps[i].profile = fa.profile;
|
|
1266
1449
|
config.apps[i].profileModel = fa.profileModel;
|
|
1450
|
+
// 分身 Bash 三态热生效:gate 在 decide 时经 getter 现读 app.bashMode
|
|
1451
|
+
config.apps[i].bashMode = fa.bashMode;
|
|
1267
1452
|
});
|
|
1268
1453
|
}
|
|
1269
1454
|
return null;
|
|
@@ -1287,8 +1472,41 @@ export async function startBridge(configPath = CONFIG_PATH) {
|
|
|
1287
1472
|
if (removed > 0)
|
|
1288
1473
|
console.log(`[transcripts] 已清理 ${removed} 个过期落盘文件`);
|
|
1289
1474
|
}
|
|
1475
|
+
// 长期记忆(§6.6 B1):启动时 Admin API 签发 per-bot user key——api_key 模式才实际签发,
|
|
1476
|
+
// dev 模式服务端禁用 Admin API(身份经 OPENVIKING_USER → X-OpenViking-User header 隔离,
|
|
1477
|
+
// 跳过即正解);memory 段热重载变化时后台重签(account/root key 改动会使旧 key 失配)。
|
|
1478
|
+
// key 只进内存缓存与子进程 env,永不落日志/配置页
|
|
1479
|
+
const memoryUserKeys = new Map();
|
|
1480
|
+
const refreshMemoryKeys = async () => {
|
|
1481
|
+
if (!config.memory?.enabled) {
|
|
1482
|
+
memoryUserKeys.clear();
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
const outcomes = await ensureMemoryUserKeys(config.memory, config.apps.map((a) => a.appId));
|
|
1486
|
+
memoryUserKeys.clear();
|
|
1487
|
+
for (const o of outcomes.values()) {
|
|
1488
|
+
if (o.userKey) {
|
|
1489
|
+
memoryUserKeys.set(o.appId, o.userKey);
|
|
1490
|
+
console.log(`[memory] bot ${o.appId}:user key 已签发(api_key 模式)`);
|
|
1491
|
+
}
|
|
1492
|
+
else if (o.error)
|
|
1493
|
+
console.warn(`[memory] bot ${o.appId}:user key ${o.error}`);
|
|
1494
|
+
else
|
|
1495
|
+
console.log(`[memory] bot ${o.appId}:${o.skipped}`);
|
|
1496
|
+
}
|
|
1497
|
+
};
|
|
1498
|
+
if (config.memory?.enabled)
|
|
1499
|
+
await refreshMemoryKeys();
|
|
1500
|
+
const memoryProvider = createMemoryProvider(config, memoryUserKeys);
|
|
1290
1501
|
// reloader 共享:所有 bridge 持有同一 config 对象引用,mutate 一处全员可见
|
|
1291
|
-
const reloadConfig = createConfigReloader(config, configPath
|
|
1502
|
+
const reloadConfig = createConfigReloader(config, configPath, {
|
|
1503
|
+
onMemoryChanged: (memory) => {
|
|
1504
|
+
if (memory?.enabled)
|
|
1505
|
+
void refreshMemoryKeys();
|
|
1506
|
+
else
|
|
1507
|
+
memoryUserKeys.clear();
|
|
1508
|
+
},
|
|
1509
|
+
});
|
|
1292
1510
|
// 旧版(≤0.3.x)缺省独立目录的一次性迁移提示:目录还在则其中历史会话不再可 /resume
|
|
1293
1511
|
//(本版起所有机器人统一 ~/.claude,会话无法跨数据目录 resume)。不做自动迁移(双目录合并无安全做法)
|
|
1294
1512
|
for (const app of config.apps) {
|
|
@@ -1299,6 +1517,8 @@ export async function startBridge(configPath = CONFIG_PATH) {
|
|
|
1299
1517
|
}
|
|
1300
1518
|
}
|
|
1301
1519
|
// 每 app 一对 gateway+bridge:长连接/会话池/并发闸/Claude Code 环境/落盘目录全部 per-app 隔离
|
|
1520
|
+
// 安全面审计:单实例按 appId 分文件落盘(CONFIG_DIR/security/audit-<appId>.jsonl)
|
|
1521
|
+
const securityAudit = new SecurityAudit(join(CONFIG_DIR, 'security'));
|
|
1302
1522
|
const runners = config.apps.map((app) => {
|
|
1303
1523
|
const tag = `[app:${app.name}]`;
|
|
1304
1524
|
const gateway = new FeishuGateway(app, {
|
|
@@ -1307,6 +1527,13 @@ export async function startBridge(configPath = CONFIG_PATH) {
|
|
|
1307
1527
|
strictGroupMention: config.apps.length > 1,
|
|
1308
1528
|
// 卡片宽度热生效:getter 现读 config.card(reloader 原地 mutate 同一对象)
|
|
1309
1529
|
cardWidth: () => config.card?.width ?? 'default',
|
|
1530
|
+
// R3 出站脱敏(恒开,全部机器人):getter 现读 config.security → 热重载即生效
|
|
1531
|
+
redactor: () => getRedactor(config.security),
|
|
1532
|
+
onSecurityEvent: (ev) => {
|
|
1533
|
+
securityAudit.write(app.appId, ev);
|
|
1534
|
+
if (ev.kind !== 'redact')
|
|
1535
|
+
console.warn(tag, '[安全事件]', ev.kind);
|
|
1536
|
+
},
|
|
1310
1537
|
});
|
|
1311
1538
|
const deps = {
|
|
1312
1539
|
gateway: {
|
|
@@ -1327,6 +1554,8 @@ export async function startBridge(configPath = CONFIG_PATH) {
|
|
|
1327
1554
|
store: SessionStore.load(join(CONFIG_DIR, `sessions.${app.appId}.json`)),
|
|
1328
1555
|
executor: runTask,
|
|
1329
1556
|
transcript: new TranscriptWriter(join(CONFIG_DIR, 'transcripts', app.appId)),
|
|
1557
|
+
audit: securityAudit,
|
|
1558
|
+
memory: memoryProvider,
|
|
1330
1559
|
};
|
|
1331
1560
|
return { app, gateway, bridge: createBridge(config, app, deps, { reloadConfig }) };
|
|
1332
1561
|
});
|
|
@@ -1352,6 +1581,10 @@ export async function startBridge(configPath = CONFIG_PATH) {
|
|
|
1352
1581
|
webHandle?.close();
|
|
1353
1582
|
for (const r of runners)
|
|
1354
1583
|
r.gateway.close();
|
|
1584
|
+
// 常驻 CLI 子进程一并回收(优雅退出,不赖 stdio 断连兜底)
|
|
1585
|
+
killAllResidentSessions();
|
|
1586
|
+
// B3 受管 OpenViking 服务一并关停(embedded 模式随 LCB 生命周期;独立手动服务不受影响)
|
|
1587
|
+
stopManagedServer();
|
|
1355
1588
|
clearPidFile();
|
|
1356
1589
|
process.exit(0);
|
|
1357
1590
|
};
|