@shgroup/dsh-serenity-hooks 1.69.0 → 1.69.2
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 +7 -4
- package/dsh.plugin.json +1 -1
- package/lib/{ccc-roots-RMzGU-CJ.js → ccc-roots-RzasspMr.js} +1 -1
- package/lib/ccc.d.ts +0 -63
- package/lib/client.js +4 -338
- package/lib/index.d.ts +0 -28
- package/lib/index.js +42 -1239
- package/lib/{message-source-Catc7q1z.js → message-source-DWWECjGB.js} +1 -1
- package/lib/ports.d.ts +3 -5
- package/lib/{settings-section-B1YzjFz6.js → settings-section-0TPYWug9.js} +3 -29
- package/lib/settings-section.d.ts +0 -22
- package/lib/{skiff-role-Boc4SKct.js → skiff-role-cwxAt0gE.js} +29 -16
- package/lib/skiff-role.d.ts +18 -3
- package/lib/weixin-route-D_DvgPqy.js +558 -0
- package/package.json +1 -1
- package/lib/acp-auth.d.ts +0 -273
- package/lib/acp-face.d.ts +0 -244
- package/lib/localstore-ops-ylQoPz1Q.js +0 -282
- package/lib/weixin-route-BoSC-uPp.js +0 -281
package/lib/index.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.js";
|
|
2
|
-
import { c as hostInjected, d as hostSessions, f as hostSettings, h as hostWebServer, i as cccRootForExec, l as hostLlm, m as hostWeb, n as agentCwdFor, o as listCccs, p as hostSubagents, r as cccRootForCwd, s as hostAgents, t as NO_CCC_FROM_AGENT_CWD, u as hostService } from "./ccc-roots-
|
|
2
|
+
import { c as hostInjected, d as hostSessions, f as hostSettings, h as hostWebServer, i as cccRootForExec, l as hostLlm, m as hostWeb, n as agentCwdFor, o as listCccs, p as hostSubagents, r as cccRootForCwd, s as hostAgents, t as NO_CCC_FROM_AGENT_CWD, u as hostService } from "./ccc-roots-RzasspMr.js";
|
|
3
3
|
import { _ as resolveInside, a as findSerenityRoot$1, c as matchBlacklist, d as readCccName$1, f as readCheapWorkerConfig, h as readSafeModeHiddenTools, i as findGitRoot, l as pathInside, m as readExclusiveTools, n as SAFE_MODE_MARKER, o as isSafeModeOn, p as readCognitiveTrajectorySkills, r as classifyPath, s as loadSerenityConfig, t as DEFAULT_SERENITY_CONFIG_PATHS, u as readBlacklist } from "./ccc-C33QWoWZ.js";
|
|
4
|
-
import { A as summarize, C as readTrajectoryUid, D as setActiveSessionInfo, E as sessionsRoot, F as localIdStamp, M as isoLocal, N as localFileStamp, O as setTrajectoryUid, P as localHuman, S as readActiveSessionMd, T as sessionEvents$1, _ as getActiveSessionInfo, a as pruneMissingBindings, b as namingTitleFor, c as setBindingStore, d as UID_FILENAME, f as archiveSession, g as findSession, h as extractSessionMdPathFromText, i as listBoundSessionIds, j as useSession, k as showSession, l as COGNITIVE_TRAJECTORY_ACTIONS, m as createSession, n as PLUGIN_SOURCE, o as readLastBound, p as clearActiveSessionInfo, r as appendBound, s as resolveSessionCognitiveTrajectoryLabel, u as DEFAULT_SESSION_SCOPE, v as listActiveTrajectories, w as resolveSessionByTitle, x as parseSessionContextFromEvents, y as listSessions } from "./message-source-
|
|
5
|
-
import { a as
|
|
6
|
-
import { a as
|
|
7
|
-
import { a as isSkiffSessionId, c as resolveSkiffKind, f as systemPromptSource, i as createRolePromptReader, l as roleMsmWhitelist, n as buildSkiffBasePrompt, o as readSkiffRoles, r as cognitiveTrajectorySubset, s as resolveRoleSystemPrompt, u as roleToolWhitelist } from "./skiff-role-
|
|
4
|
+
import { A as summarize, C as readTrajectoryUid, D as setActiveSessionInfo, E as sessionsRoot, F as localIdStamp, M as isoLocal, N as localFileStamp, O as setTrajectoryUid, P as localHuman, S as readActiveSessionMd, T as sessionEvents$1, _ as getActiveSessionInfo, a as pruneMissingBindings, b as namingTitleFor, c as setBindingStore, d as UID_FILENAME, f as archiveSession, g as findSession, h as extractSessionMdPathFromText, i as listBoundSessionIds, j as useSession, k as showSession, l as COGNITIVE_TRAJECTORY_ACTIONS, m as createSession, n as PLUGIN_SOURCE, o as readLastBound, p as clearActiveSessionInfo, r as appendBound, s as resolveSessionCognitiveTrajectoryLabel, u as DEFAULT_SESSION_SCOPE, v as listActiveTrajectories, w as resolveSessionByTitle, x as parseSessionContextFromEvents, y as listSessions } from "./message-source-DWWECjGB.js";
|
|
5
|
+
import { a as readWeixinCredential, c as weixinInboundDir, d as LOCALSTORE_SCOPES, f as checkLocalstoreGitCompliance, g as runLocalStore, h as readStore, i as matchWeixinRoute, l as weixinSessionIdFor, m as readGitTrack, n as extractWeixinText, o as readWeixinSettings, p as localstorePath, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia } from "./weixin-route-D_DvgPqy.js";
|
|
6
|
+
import { a as GATEWAY_PORT, c as SKIFF_DEBUG_PORT, d as skiffRoleFor$1, f as skiffSessionInfo$1, i as FACE_PORTS, l as WEIXIN_SEND_PORT, m as ACC_VERSION, n as readSimpleSettings, o as MAIN_WEB_PORT, p as unregisterSkiffSession$1, r as registerSettingsSection, s as MECHANISM_PORTS, u as registerSkiffSession$1 } from "./settings-section-0TPYWug9.js";
|
|
7
|
+
import { a as isSkiffSessionId, c as resolveSkiffKind, f as systemPromptSource, i as createRolePromptReader, l as roleMsmWhitelist, n as buildSkiffBasePrompt, o as readSkiffRoles, r as cognitiveTrajectorySubset, s as resolveRoleSystemPrompt, u as roleToolWhitelist } from "./skiff-role-cwxAt0gE.js";
|
|
8
8
|
import { REGISTERED_TOOLS } from "./invariant.js";
|
|
9
|
-
import { a as readWeixinCredential, c as weixinInboundDir, i as matchWeixinRoute, l as weixinSessionIdFor, n as extractWeixinText, o as readWeixinSettings, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia } from "./weixin-route-BoSC-uPp.js";
|
|
10
9
|
import { a as markdownToPlainText, c as sendTyping, i as getUpdates, l as sniffImageExt, n as downloadMedia, o as sendFileMessage, r as getConfig, s as sendTextMessage, t as TypingStatus } from "./weixin-api-BrRcPiQs.js";
|
|
11
10
|
import { createRequire } from "node:module";
|
|
12
11
|
import z from "@deepseek-ai/schemastery";
|
|
@@ -1085,8 +1084,8 @@ Directory of ACC capabilities — each area lists where to go for details (guide
|
|
|
1085
1084
|
cheap-worker(杂工 agent 编排——guide 子命令:cheap-worker guide)
|
|
1086
1085
|
专属工具(**默认对所有 CCC 隐藏**,只有在自己 exclusiveTools 里点名的 CCC 可见)→ acc-diag(运行态诊断)
|
|
1087
1086
|
④ 角色与对外面 → container_admin role(Skiff 认知子集角色:guide/validate/apply/list——skiff_admin 已并入)
|
|
1088
|
-
3099 Skiff
|
|
1089
|
-
|
|
1087
|
+
3099 Skiff 调试问答页(人工调试面)。外部程序化接入用官方 dsh --profile acp
|
|
1088
|
+
+ 现成 ACP 客户端;本插件不提供 ACP 面
|
|
1090
1089
|
⑤ 自主与接入 → cognitive_trajectory send-now(跨轨迹的**即时**递送,**不排队**:live 目标在跑就当场注入当前轮、空闲则立即起轮;非 live 冷载入——等效于直接 wake;**有同步回执**)
|
|
1091
1090
|
cognitive_trajectory send-later(跨轨迹的"未来时刻 + 一条 message";fire-and-forget,无回执无回收)
|
|
1092
1091
|
weixin 微信桥(配置/扫码/路由/消息 hook——CCC 侧 weixin-doctor MSM:msm("weixin-doctor", ["guide"]))
|
|
@@ -3969,10 +3968,31 @@ async function createSkiffAgent(ctx, root, roleName, role, defaultModel, session
|
|
|
3969
3968
|
console.warn(`[serenity-hooks] skiff 角色 "${roleName}" 专属 SESSION 处理失败(不影响 agent 创建): ${String(err?.message ?? err)}`);
|
|
3970
3969
|
}
|
|
3971
3970
|
try {
|
|
3971
|
+
let readerPath = role?.systemPromptFile?.trim();
|
|
3972
|
+
let reader = rolePrompt;
|
|
3973
|
+
const liveRolePrompt = () => {
|
|
3974
|
+
let live = role;
|
|
3975
|
+
try {
|
|
3976
|
+
const fresh = readSkiffRoles(root).get(roleName);
|
|
3977
|
+
if (fresh) live = fresh;
|
|
3978
|
+
} catch {}
|
|
3979
|
+
const wantPath = live?.systemPromptFile?.trim();
|
|
3980
|
+
if (wantPath !== readerPath) {
|
|
3981
|
+
reader = kind === "persistent" ? createRolePromptReader(root, live) : (() => {
|
|
3982
|
+
let snapshot = "";
|
|
3983
|
+
try {
|
|
3984
|
+
snapshot = resolveRoleSystemPrompt(root, live);
|
|
3985
|
+
} catch {}
|
|
3986
|
+
return () => snapshot;
|
|
3987
|
+
})();
|
|
3988
|
+
readerPath = wantPath;
|
|
3989
|
+
}
|
|
3990
|
+
return reader();
|
|
3991
|
+
};
|
|
3972
3992
|
agent.ctx.systemPrompt.section({
|
|
3973
3993
|
name: "serenity-skiff",
|
|
3974
3994
|
order: -60,
|
|
3975
|
-
text: () => [buildSkiffBasePrompt(roleName
|
|
3995
|
+
text: () => [buildSkiffBasePrompt(roleName), liveRolePrompt()].filter(Boolean).join("\n")
|
|
3976
3996
|
});
|
|
3977
3997
|
} catch (err) {
|
|
3978
3998
|
console.warn(`[serenity-hooks] skiff 系统提示词注册失败: ${String(err?.message ?? err)}`);
|
|
@@ -14990,8 +15010,6 @@ const FILE_UPLOAD_PATH = "/serenity/file-upload";
|
|
|
14990
15010
|
const CONFIG_PATH = "/serenity/config";
|
|
14991
15011
|
const CCCS_PATH = "/serenity/cccs";
|
|
14992
15012
|
const WEIXIN_PATH = "/serenity/weixin";
|
|
14993
|
-
/** 🔴 v1.59.1:ACP 账户台账(面板数据面;形状照 `/serenity/weixin`) */
|
|
14994
|
-
const ACP_PATH = "/serenity/acp";
|
|
14995
15013
|
/** 图片落盘目录(CCC 根相对;S142 图片自动识别基础设施——粘贴图片落盘供 agent 经 CCC vlm MSM 自主处理) */
|
|
14996
15014
|
const IMAGE_UPLOAD_DIR = "_tmp/images_from_user";
|
|
14997
15015
|
const IMAGE_MEDIA_TYPES = /* @__PURE__ */ new Set([
|
|
@@ -15336,11 +15354,11 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15336
15354
|
sendJson$3(res, 400, { error: chk.error });
|
|
15337
15355
|
return;
|
|
15338
15356
|
}
|
|
15339
|
-
const { readWeixinSettings } = await import("./weixin-route-
|
|
15357
|
+
const { readWeixinSettings } = await import("./weixin-route-D_DvgPqy.js").then((n) => n.u);
|
|
15340
15358
|
const { weixinBridgeStatus } = await Promise.resolve().then(() => weixin_bridge_exports);
|
|
15341
15359
|
const settings = readWeixinSettings(chk.root);
|
|
15342
15360
|
const bridge = weixinBridgeStatus().find((b) => b.ccc === chk.root);
|
|
15343
|
-
const { readWeixinCredential } = await import("./weixin-route-
|
|
15361
|
+
const { readWeixinCredential } = await import("./weixin-route-D_DvgPqy.js").then((n) => n.u);
|
|
15344
15362
|
const accounts = (settings.accounts ?? []).map((a) => ({
|
|
15345
15363
|
accountId: a.accountId,
|
|
15346
15364
|
name: a.name ?? void 0,
|
|
@@ -15372,7 +15390,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15372
15390
|
const { fetchQRCode } = await import("./weixin-api-BrRcPiQs.js").then((n) => n.u);
|
|
15373
15391
|
const { randomUUID } = await import("node:crypto");
|
|
15374
15392
|
for (const [key, v] of weixinLogins) if (Date.now() - v.startedAt > WEIXIN_LOGIN_TTL_MS) weixinLogins.delete(key);
|
|
15375
|
-
const { readWeixinSettings } = await import("./weixin-route-
|
|
15393
|
+
const { readWeixinSettings } = await import("./weixin-route-D_DvgPqy.js").then((n) => n.u);
|
|
15376
15394
|
const qr = await fetchQRCode({ botType: readWeixinSettings(root).botType ?? void 0 });
|
|
15377
15395
|
const loginKey = randomUUID();
|
|
15378
15396
|
weixinLogins.set(loginKey, {
|
|
@@ -15393,7 +15411,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15393
15411
|
sendJson$3(res, 400, { error: "missing accountId" });
|
|
15394
15412
|
return;
|
|
15395
15413
|
}
|
|
15396
|
-
const { removeWeixinAccount } = await import("./weixin-route-
|
|
15414
|
+
const { removeWeixinAccount } = await import("./weixin-route-D_DvgPqy.js").then((n) => n.u);
|
|
15397
15415
|
const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
|
|
15398
15416
|
removeWeixinAccount(root, body.accountId);
|
|
15399
15417
|
syncCccBridge(ctx, root);
|
|
@@ -15406,8 +15424,8 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15406
15424
|
sendJson$3(res, 400, { error: "invalid routes (expected [{user, role}, ...])" });
|
|
15407
15425
|
return;
|
|
15408
15426
|
}
|
|
15409
|
-
const { saveWeixinRoutes } = await import("./weixin-route-
|
|
15410
|
-
const { readSkiffRoles } = await import("./skiff-role-
|
|
15427
|
+
const { saveWeixinRoutes } = await import("./weixin-route-D_DvgPqy.js").then((n) => n.u);
|
|
15428
|
+
const { readSkiffRoles } = await import("./skiff-role-cwxAt0gE.js").then((n) => n.d);
|
|
15411
15429
|
const roles = readSkiffRoles(root);
|
|
15412
15430
|
for (const r of routes) if (!roles.has(r.role)) {
|
|
15413
15431
|
sendJson$3(res, 400, { error: `unknown role: ${r.role} (not in ${root} skiff.roles)` });
|
|
@@ -15419,7 +15437,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15419
15437
|
}
|
|
15420
15438
|
if (body.action === "set-enabled") {
|
|
15421
15439
|
const enabled = body.enabled === true;
|
|
15422
|
-
const { readWeixinSettings, setWeixinEnabled } = await import("./weixin-route-
|
|
15440
|
+
const { readWeixinSettings, setWeixinEnabled } = await import("./weixin-route-D_DvgPqy.js").then((n) => n.u);
|
|
15423
15441
|
const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
|
|
15424
15442
|
const settings = readWeixinSettings(root);
|
|
15425
15443
|
if (enabled && (settings.accounts ?? []).length === 0) {
|
|
@@ -15463,7 +15481,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15463
15481
|
return;
|
|
15464
15482
|
}
|
|
15465
15483
|
const { pollQRStatus } = await import("./weixin-api-BrRcPiQs.js").then((n) => n.u);
|
|
15466
|
-
const { readWeixinSettings } = await import("./weixin-route-
|
|
15484
|
+
const { readWeixinSettings } = await import("./weixin-route-D_DvgPqy.js").then((n) => n.u);
|
|
15467
15485
|
const settings = readWeixinSettings(login.root);
|
|
15468
15486
|
const status = await pollQRStatus({
|
|
15469
15487
|
baseUrl: void 0,
|
|
@@ -15478,7 +15496,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15478
15496
|
});
|
|
15479
15497
|
return;
|
|
15480
15498
|
}
|
|
15481
|
-
const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./weixin-route-
|
|
15499
|
+
const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./weixin-route-D_DvgPqy.js").then((n) => n.u);
|
|
15482
15500
|
const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
|
|
15483
15501
|
const accountId = nextWeixinAccountId(settings);
|
|
15484
15502
|
upsertWeixinAccount(login.root, {
|
|
@@ -15562,7 +15580,6 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15562
15580
|
}
|
|
15563
15581
|
}
|
|
15564
15582
|
});
|
|
15565
|
-
registerAcpAccountsApi(ctx);
|
|
15566
15583
|
}
|
|
15567
15584
|
/** 进行中的扫码登录(loginKey → 状态;进程内,5min 有效) */
|
|
15568
15585
|
const weixinLogins = /* @__PURE__ */ new Map();
|
|
@@ -15583,99 +15600,6 @@ function requireCcc(root) {
|
|
|
15583
15600
|
root: serenityRoot
|
|
15584
15601
|
};
|
|
15585
15602
|
}
|
|
15586
|
-
/** ACP 账户台账端点(面板专用;形状照 `/serenity/weixin`) */
|
|
15587
|
-
function registerAcpAccountsApi(ctx) {
|
|
15588
|
-
ctx.webServer.register({
|
|
15589
|
-
kind: "exact",
|
|
15590
|
-
path: ACP_PATH,
|
|
15591
|
-
handler: async (req, res) => {
|
|
15592
|
-
try {
|
|
15593
|
-
if (!requireWebUi(req, res, "ACP 账户配置仅限 WebUI(client 专用)")) return;
|
|
15594
|
-
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
15595
|
-
if (req.method === "GET") {
|
|
15596
|
-
const chk = requireCcc(url.searchParams.get("ccc") ?? void 0);
|
|
15597
|
-
if (!chk.ok) {
|
|
15598
|
-
sendJson$3(res, 400, { error: chk.error });
|
|
15599
|
-
return;
|
|
15600
|
-
}
|
|
15601
|
-
const { acpChannelStatus, listAcpRoots } = await Promise.resolve().then(() => acp_auth_exports);
|
|
15602
|
-
const { listCccs } = await import("./ccc-roots-RMzGU-CJ.js").then((n) => n.a);
|
|
15603
|
-
const st = acpChannelStatus(chk.root);
|
|
15604
|
-
const all = (await listCccs(ctx)).map((e) => e.root);
|
|
15605
|
-
sendJson$3(res, 200, {
|
|
15606
|
-
channel: st.channel,
|
|
15607
|
-
enabled: st.enabled,
|
|
15608
|
-
accounts: st.accounts,
|
|
15609
|
-
roots: listAcpRoots(all)
|
|
15610
|
-
});
|
|
15611
|
-
return;
|
|
15612
|
-
}
|
|
15613
|
-
if (req.method !== "POST") {
|
|
15614
|
-
sendJson$3(res, 405, { error: "method not allowed" });
|
|
15615
|
-
return;
|
|
15616
|
-
}
|
|
15617
|
-
const raw = await readBody$3(req, 65536);
|
|
15618
|
-
const body = JSON.parse(raw);
|
|
15619
|
-
const chk = requireCcc(body.ccc);
|
|
15620
|
-
if (!chk.ok) {
|
|
15621
|
-
sendJson$3(res, 400, { error: chk.error });
|
|
15622
|
-
return;
|
|
15623
|
-
}
|
|
15624
|
-
const root = chk.root;
|
|
15625
|
-
const acp = await Promise.resolve().then(() => acp_auth_exports);
|
|
15626
|
-
if (body.action === "upsert") {
|
|
15627
|
-
const account = acp.normalizeAcpAccount(body.account);
|
|
15628
|
-
if (!account) {
|
|
15629
|
-
sendJson$3(res, 400, { error: "invalid account (accountId 与 role 均必填)" });
|
|
15630
|
-
return;
|
|
15631
|
-
}
|
|
15632
|
-
const { readSkiffRoles } = await import("./skiff-role-Boc4SKct.js").then((n) => n.d);
|
|
15633
|
-
const roles = readSkiffRoles(root);
|
|
15634
|
-
if (!roles.has(account.role)) {
|
|
15635
|
-
sendJson$3(res, 400, { error: `role "${account.role}" 不在该 CCC 的 skiff.roles 里(现有:${[...roles.keys()].join(", ") || "无"})` });
|
|
15636
|
-
return;
|
|
15637
|
-
}
|
|
15638
|
-
acp.upsertAcpAccount(root, account);
|
|
15639
|
-
const created = acp.ensureAcpChannelTokens(root).includes(account.accountId);
|
|
15640
|
-
sendJson$3(res, 200, {
|
|
15641
|
-
saved: true,
|
|
15642
|
-
accounts: acp.acpChannelStatus(root).accounts,
|
|
15643
|
-
token: created ? acp.readAcpToken(root, account.accountId) : void 0
|
|
15644
|
-
});
|
|
15645
|
-
return;
|
|
15646
|
-
}
|
|
15647
|
-
if (body.action === "remove") {
|
|
15648
|
-
const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
|
|
15649
|
-
if (accountId === "") {
|
|
15650
|
-
sendJson$3(res, 400, { error: "missing accountId" });
|
|
15651
|
-
return;
|
|
15652
|
-
}
|
|
15653
|
-
acp.removeAcpAccount(root, accountId);
|
|
15654
|
-
sendJson$3(res, 200, {
|
|
15655
|
-
removed: accountId,
|
|
15656
|
-
accounts: acp.acpChannelStatus(root).accounts
|
|
15657
|
-
});
|
|
15658
|
-
return;
|
|
15659
|
-
}
|
|
15660
|
-
if (body.action === "rotate") {
|
|
15661
|
-
const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
|
|
15662
|
-
if (accountId === "") {
|
|
15663
|
-
sendJson$3(res, 400, { error: "missing accountId" });
|
|
15664
|
-
return;
|
|
15665
|
-
}
|
|
15666
|
-
sendJson$3(res, 200, {
|
|
15667
|
-
rotated: accountId,
|
|
15668
|
-
token: acp.resetAcpToken(root, accountId)
|
|
15669
|
-
});
|
|
15670
|
-
return;
|
|
15671
|
-
}
|
|
15672
|
-
sendJson$3(res, 400, { error: `unknown action: ${String(body.action)}` });
|
|
15673
|
-
} catch (err) {
|
|
15674
|
-
sendJson$3(res, 400, { error: err?.message ?? String(err) });
|
|
15675
|
-
}
|
|
15676
|
-
}
|
|
15677
|
-
});
|
|
15678
|
-
}
|
|
15679
15603
|
//#endregion
|
|
15680
15604
|
//#region src/voyage-vocab.ts
|
|
15681
15605
|
/**
|
|
@@ -20860,7 +20784,7 @@ function manualOutputFallbackNeeded(input) {
|
|
|
20860
20784
|
*/
|
|
20861
20785
|
async function resolveCccRootByAlias(ctx, alias) {
|
|
20862
20786
|
try {
|
|
20863
|
-
const { listCccs } = await import("./ccc-roots-
|
|
20787
|
+
const { listCccs } = await import("./ccc-roots-RzasspMr.js").then((n) => n.a);
|
|
20864
20788
|
const entries = await listCccs(ctx);
|
|
20865
20789
|
const byName = entries.find((e) => e.name === alias);
|
|
20866
20790
|
if (byName) return byName.root;
|
|
@@ -21359,7 +21283,7 @@ function syncHumanChannel(ctx) {
|
|
|
21359
21283
|
(async () => {
|
|
21360
21284
|
try {
|
|
21361
21285
|
stopMachineLoops();
|
|
21362
|
-
const { listCccs } = await import("./ccc-roots-
|
|
21286
|
+
const { listCccs } = await import("./ccc-roots-RzasspMr.js").then((n) => n.a);
|
|
21363
21287
|
for (const entry of await listCccs(ctx)) syncCccBridge(ctx, entry.root);
|
|
21364
21288
|
} catch {}
|
|
21365
21289
|
})();
|
|
@@ -21595,7 +21519,7 @@ function matchCcc(input, candidates) {
|
|
|
21595
21519
|
}
|
|
21596
21520
|
/** 组装候选列表(`ccc-roots.listCccs` 投影 + `.serenity` 名;动态 import 保持本模块静态依赖轻量) */
|
|
21597
21521
|
async function collectCandidates(ctx) {
|
|
21598
|
-
const { listCccs } = await import("./ccc-roots-
|
|
21522
|
+
const { listCccs } = await import("./ccc-roots-RzasspMr.js").then((n) => n.a);
|
|
21599
21523
|
const entries = await listCccs(ctx, { defaultRoot: process.cwd() });
|
|
21600
21524
|
const seen = /* @__PURE__ */ new Set();
|
|
21601
21525
|
const out = [];
|
|
@@ -21805,1120 +21729,6 @@ function registerWeixinSendApi(ctx) {
|
|
|
21805
21729
|
sync();
|
|
21806
21730
|
}
|
|
21807
21731
|
//#endregion
|
|
21808
|
-
//#region src/acp-auth.ts
|
|
21809
|
-
/**
|
|
21810
|
-
* acp-auth.ts — ACP 面的**认证 + 身份**层(v1.59.0,owner 2026-10-17 令)
|
|
21811
|
-
*
|
|
21812
|
-
* ## 它解决什么
|
|
21813
|
-
*
|
|
21814
|
-
* owner 令(逐字):「将微信桥升级为 IM 桥,每个 CCC 可以选择微信实现或 ACP 实现,
|
|
21815
|
-
* 微信实现保留不变向前兼容,**ACP 实现就可以暴露端口并绑定 skiff,并且要随即生成认证用的 token**」。
|
|
21816
|
-
*
|
|
21817
|
-
* 面 E(`acp-face.ts`)原本**无认证、无 CCC 归属、无角色** —— 本模块补上这三件:
|
|
21818
|
-
* ① **token**:每 CCC 一枚,落**该 CCC 的 localstore**(credential scope)。
|
|
21819
|
-
* ② **CCC 归属**:连接建立时由 token 反查 ⇒ **服务端权威**(不拿客户端自述的 `cwd` 当身份)。
|
|
21820
|
-
* ③ **角色**:该 CCC 的 `im.acp.role` ⇒ 供装配层把 ACP 会话套上 skiff 角色。
|
|
21821
|
-
*
|
|
21822
|
-
* ## 🔴 架构决策:一面多 token,不是每 CCC 一个端口
|
|
21823
|
-
*
|
|
21824
|
-
* | | 甲:每 CCC 一个端口 | **乙:一面多 token** ← 选它 |
|
|
21825
|
-
* |---|---|---|
|
|
21826
|
-
* | 端口 | 随 CCC 数增长 | **固定 3200** |
|
|
21827
|
-
* | 身份来源 | 端口号 | **token(一一对应 CCC)** |
|
|
21828
|
-
*
|
|
21829
|
-
* **判据**:`token → CCC` 是一一对应,而 `cwd` 是**客户端可填的**。
|
|
21830
|
-
* ⇒ 用 token 定 CCC 比用 cwd 定 CCC 强:前者**服务端权威**,后者**客户端自述**。
|
|
21831
|
-
* ⇒ 连接一建立就知道"这是哪个 CCC",**该连接的所有会话都归它**,角色绑定无需猜。
|
|
21832
|
-
* ⚠️ `cwd` 仍要校验(见 `cwdInsideRoot`):防一枚 token 越权到别的 CCC。
|
|
21833
|
-
*
|
|
21834
|
-
* ## 🔴 为什么认证放在 WS upgrade 层,而不在 ACP 协议里
|
|
21835
|
-
*
|
|
21836
|
-
* `dsh-acp` 的 `authenticate()` **恒成功**且 `authMethods: []`(读其 `lib/index.js` 逐字),
|
|
21837
|
-
* 且它**不导出**可替换的认证缝 ⇒ 改协议层要重写它的接线(P0′ 已实测此路不通)。
|
|
21838
|
-
* 而**我们自己持有 HTTP server**(自持面)⇒ 在 `upgrade` **升级之前**校验,
|
|
21839
|
-
* 失败即 `401 + destroy` ⇒ **连接根本进不来**。
|
|
21840
|
-
*
|
|
21841
|
-
* ## 边界
|
|
21842
|
-
*
|
|
21843
|
-
* 本模块**零 DSH 依赖**(只 `node:crypto` / `node:fs` / `node:path` 与本仓纯逻辑)⇒ 可独立单测。
|
|
21844
|
-
*/
|
|
21845
|
-
var acp_auth_exports = /* @__PURE__ */ __exportAll({
|
|
21846
|
-
acpChannelEnabled: () => acpChannelEnabled,
|
|
21847
|
-
acpChannelStatus: () => acpChannelStatus,
|
|
21848
|
-
acpSessionKeyFor: () => acpSessionKeyFor,
|
|
21849
|
-
acpTokenKeyFor: () => acpTokenKeyFor,
|
|
21850
|
-
cwdInsideRoot: () => cwdInsideRoot,
|
|
21851
|
-
ensureAcpChannelTokens: () => ensureAcpChannelTokens,
|
|
21852
|
-
ensureAcpToken: () => ensureAcpToken,
|
|
21853
|
-
expandAcpAccounts: () => expandAcpAccounts,
|
|
21854
|
-
extractToken: () => extractToken,
|
|
21855
|
-
listAcpRoots: () => listAcpRoots,
|
|
21856
|
-
normalizeAcpAccount: () => normalizeAcpAccount,
|
|
21857
|
-
readAcpMainSession: () => readAcpMainSession,
|
|
21858
|
-
readAcpToken: () => readAcpToken,
|
|
21859
|
-
readImSettings: () => readImSettings,
|
|
21860
|
-
removeAcpAccount: () => removeAcpAccount,
|
|
21861
|
-
resetAcpToken: () => resetAcpToken,
|
|
21862
|
-
resolveTokenAccount: () => resolveTokenAccount,
|
|
21863
|
-
safeEqual: () => safeEqual,
|
|
21864
|
-
saveAcpAccounts: () => saveAcpAccounts,
|
|
21865
|
-
tokenFingerprint: () => tokenFingerprint,
|
|
21866
|
-
upsertAcpAccount: () => upsertAcpAccount,
|
|
21867
|
-
writeAcpMainSession: () => writeAcpMainSession
|
|
21868
|
-
});
|
|
21869
|
-
/** 多账户凭据键前缀(`IM_ACP_TOKEN_<ACCOUNT_ID>`;与微信桥 `WEIXIN_<ID>_<FIELD>` 同构) */
|
|
21870
|
-
const ACP_TOKEN_PREFIX = "IM_ACP_TOKEN_";
|
|
21871
|
-
/**
|
|
21872
|
-
* `accountId` → UPPER_SNAKE 片段(`agmente-phone` → `AGMENTE_PHONE`)。
|
|
21873
|
-
*
|
|
21874
|
-
* 🔴 **与 `weixin-route.ts` 的 `accountKeyPart` 逐字同规则** —— 刻意复制而非共享:
|
|
21875
|
-
* 两者属不同子域(微信 vs ACP),共享会让"改微信键规则"意外改掉 ACP 的既有凭据键,
|
|
21876
|
-
* 而凭据键**改一次就等于全体客户端掉线**。⇒ 用"同规则的两份"换"互不牵连"。
|
|
21877
|
-
*/
|
|
21878
|
-
function accountKeyPart(accountId) {
|
|
21879
|
-
return accountId.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
|
|
21880
|
-
}
|
|
21881
|
-
/** 该账户的凭据键(`IM_ACP_TOKEN_<ACCOUNT_ID>`);`accountId` 必填 */
|
|
21882
|
-
function acpTokenKeyFor(accountId) {
|
|
21883
|
-
return `${ACP_TOKEN_PREFIX}${accountKeyPart(accountId)}`;
|
|
21884
|
-
}
|
|
21885
|
-
/** token 字节数(32 B = 256 bit;hex 后 64 字符) */
|
|
21886
|
-
const TOKEN_BYTES = 32;
|
|
21887
|
-
/**
|
|
21888
|
-
* 该**账户**的 ACP token(**读**;不存在返回 null)。
|
|
21889
|
-
*
|
|
21890
|
-
* 🔴 **不自动生成** —— 生成是**写操作**,只在显式要求时做(`ensureAcpToken`)。
|
|
21891
|
-
* 读路径永远无副作用:否则每次判定都会改 localstore。
|
|
21892
|
-
*
|
|
21893
|
-
* @param accountId 账户键(必填 —— 凭据按账户分离,没有"CCC 默认 token"这回事)
|
|
21894
|
-
*/
|
|
21895
|
-
function readAcpToken(root, accountId) {
|
|
21896
|
-
try {
|
|
21897
|
-
const value = getEntry(root, "credential", acpTokenKeyFor(accountId));
|
|
21898
|
-
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
21899
|
-
} catch {
|
|
21900
|
-
return null;
|
|
21901
|
-
}
|
|
21902
|
-
}
|
|
21903
|
-
/**
|
|
21904
|
-
* 生成(或返回既有)ACP token 并落该 CCC 的 localstore。
|
|
21905
|
-
*
|
|
21906
|
-
* ⚠️ **幂等**:已有 token 时**原样返回**(不轮换)—— 轮换会让在用的客户端立刻断线,
|
|
21907
|
-
* 必须是一次显式动作(`resetAcpToken`)。
|
|
21908
|
-
*
|
|
21909
|
-
* @param accountId 多账户时指明哪个账户;省略 = 单账户简写键
|
|
21910
|
-
*/
|
|
21911
|
-
function ensureAcpToken(root, accountId) {
|
|
21912
|
-
const existing = readAcpToken(root, accountId);
|
|
21913
|
-
if (existing) return existing;
|
|
21914
|
-
const token = randomBytes(TOKEN_BYTES).toString("hex");
|
|
21915
|
-
writeEntry(root, "credential", acpTokenKeyFor(accountId), token);
|
|
21916
|
-
return token;
|
|
21917
|
-
}
|
|
21918
|
-
/**
|
|
21919
|
-
* 轮换 token(显式动作;返回新值)。旧 token 立即失效。
|
|
21920
|
-
* @param accountId 账户键
|
|
21921
|
-
*/
|
|
21922
|
-
function resetAcpToken(root, accountId) {
|
|
21923
|
-
const token = randomBytes(TOKEN_BYTES).toString("hex");
|
|
21924
|
-
writeEntry(root, "credential", acpTokenKeyFor(accountId), token);
|
|
21925
|
-
return token;
|
|
21926
|
-
}
|
|
21927
|
-
/**
|
|
21928
|
-
* 常时比较(防时序侧信道)。
|
|
21929
|
-
*
|
|
21930
|
-
* 🔴 两个长度不等时 `timingSafeEqual` **会抛** ⇒ 先比长度并立刻返回 false。
|
|
21931
|
-
* ⚠️ 那一步本身**泄漏长度**(可接受:token 长度固定且公开)。
|
|
21932
|
-
*/
|
|
21933
|
-
function safeEqual(a, b) {
|
|
21934
|
-
const bufA = Buffer.from(a, "utf8");
|
|
21935
|
-
const bufB = Buffer.from(b, "utf8");
|
|
21936
|
-
if (bufA.length !== bufB.length) return false;
|
|
21937
|
-
return timingSafeEqual(bufA, bufB);
|
|
21938
|
-
}
|
|
21939
|
-
/** token 指纹(诊断面用;不可逆) */
|
|
21940
|
-
function tokenFingerprint(token) {
|
|
21941
|
-
return createHash("sha256").update(token).digest("hex").slice(0, 12);
|
|
21942
|
-
}
|
|
21943
|
-
/** 主会话键前缀(`IM_ACP_SESSION_<ACCOUNT_ID>`;与 `IM_ACP_TOKEN_<ACCOUNT_ID>` 同族同规则) */
|
|
21944
|
-
const ACP_SESSION_PREFIX = "IM_ACP_SESSION_";
|
|
21945
|
-
/**
|
|
21946
|
-
* 该账户的**主会话**键(`IM_ACP_SESSION_<ACCOUNT_ID>`)。
|
|
21947
|
-
*
|
|
21948
|
-
* 🔴 为什么放在 **credential** 节而不是 config 节:它跟 token 一样是**每账户一值的键控数据**
|
|
21949
|
-
* (键由 `accountId` 派生、随账户生命周期存亡)。放 config 节要另造一层 `section.key`
|
|
21950
|
-
* 结构,而这一层的唯一读者就是本模块 ⇒ 多一层结构 = 多一处会漂的形状。
|
|
21951
|
-
*
|
|
21952
|
-
* ⚠️ 它**不是凭据**(会话 id 不是秘密)—— 共享 credential 节只是为了复用它既有的
|
|
21953
|
-
* 「按 accountId 派生键 + 删账户时一并清理」这套语义,不引入第二套键规则。
|
|
21954
|
-
*/
|
|
21955
|
-
function acpSessionKeyFor(accountId) {
|
|
21956
|
-
return `${ACP_SESSION_PREFIX}${accountKeyPart(accountId)}`;
|
|
21957
|
-
}
|
|
21958
|
-
/**
|
|
21959
|
-
* 读该账户的主会话 id(不存在 ⇒ `null`)。
|
|
21960
|
-
*
|
|
21961
|
-
* 🔴 **读路径无副作用**(与 `readAcpToken` 同纪律):不存在的含义是「这个账户还没有主会话」,
|
|
21962
|
-
* 而那时的正确行为是**让 App 去建**(`session/list` 回空数组),不是替它编一个。
|
|
21963
|
-
*/
|
|
21964
|
-
function readAcpMainSession(root, accountId) {
|
|
21965
|
-
try {
|
|
21966
|
-
const value = getEntry(root, "credential", acpSessionKeyFor(accountId));
|
|
21967
|
-
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
21968
|
-
} catch {
|
|
21969
|
-
return null;
|
|
21970
|
-
}
|
|
21971
|
-
}
|
|
21972
|
-
/**
|
|
21973
|
-
* 记下该账户的主会话 id(**写操作**;由 `session/new` 的出站响应触发)。
|
|
21974
|
-
*
|
|
21975
|
-
* 🔴 **后写覆盖先写**(last-wins):owner 要的是"一条长期对话",而"再建一条"是用户
|
|
21976
|
-
* 在 App 里显式点「新对话」的结果 ⇒ 那时他要的就是**新的那条**。
|
|
21977
|
-
* ⛔ **被否的两种做法**:① 已有主会话就拒建(用户会看到"点了没反应");
|
|
21978
|
-
* ② 把 `session/new` 改写成 `session/resume`(响应形状不同,要让客户端认一条
|
|
21979
|
-
* **不存在于协议里**的响应 —— 那是拿协议兼容性换一个我们本来就有的语义)。
|
|
21980
|
-
*/
|
|
21981
|
-
function writeAcpMainSession(root, accountId, sessionId) {
|
|
21982
|
-
const id = String(sessionId ?? "").trim();
|
|
21983
|
-
if (id === "") return;
|
|
21984
|
-
writeEntry(root, "credential", acpSessionKeyFor(accountId), id);
|
|
21985
|
-
}
|
|
21986
|
-
/**
|
|
21987
|
-
* 从一次 HTTP upgrade 请求里取出候选 token。
|
|
21988
|
-
*
|
|
21989
|
-
* 两种携带方式都认(客户端差异;Agmente 等用具名头,脚本常用查询串):
|
|
21990
|
-
* ① `Authorization: Bearer <t>`
|
|
21991
|
-
* ② `?token=<t>`(查询串)
|
|
21992
|
-
*
|
|
21993
|
-
* @returns 取到的 token;两者都无 → null
|
|
21994
|
-
*/
|
|
21995
|
-
function extractToken(headers, url) {
|
|
21996
|
-
const auth = headers["authorization"];
|
|
21997
|
-
if (typeof auth === "string") {
|
|
21998
|
-
const m = /^Bearer\s+(.+)$/i.exec(auth.trim());
|
|
21999
|
-
if (m?.[1]) return m[1].trim();
|
|
22000
|
-
}
|
|
22001
|
-
if (typeof url === "string" && url !== "") try {
|
|
22002
|
-
const q = new URL(url, "http://placeholder.invalid").searchParams.get("token");
|
|
22003
|
-
if (q && q.trim() !== "") return q.trim();
|
|
22004
|
-
} catch {}
|
|
22005
|
-
return null;
|
|
22006
|
-
}
|
|
22007
|
-
/**
|
|
22008
|
-
* 把该 CCC 的配置**展开成账户列表**(读时现算,**无快照**)。
|
|
22009
|
-
*
|
|
22010
|
-
* 🔴 **`im.acp.accounts` 是必填项** —— 没有它就没有账户,**一个都认证不了**。
|
|
22011
|
-
* 刻意**不提供**"单账户简写"退回路径(v1.59.1):那会多出一条永不生效的分支,
|
|
22012
|
-
* 而它的唯一作用是把"配置写错了"伪装成"用了简写"。
|
|
22013
|
-
*
|
|
22014
|
-
* 判据(三条都由调用方以 fail-closed 消费):
|
|
22015
|
-
* - `enabled !== false`(缺省 true)
|
|
22016
|
-
* - `role` 非空(空 ⇒ 该账户**不出现**在列表里 ⇒ 必然登录失败)
|
|
22017
|
-
* - token 存在(不存在 ⇒ 同样不出现)
|
|
22018
|
-
*
|
|
22019
|
-
* ⚠️ 本函数**纯读,绝不生成 token** —— 生成是写操作,归 `ensureAcpChannelTokens`。
|
|
22020
|
-
* 这样 upgrade 路径可以随时现算而不冒"每次连接都写 localstore"的风险。
|
|
22021
|
-
*/
|
|
22022
|
-
function expandAcpAccounts(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22023
|
-
const im = readImSettings(root, paths);
|
|
22024
|
-
if (im.channel !== "acp" || im.acp.enabled !== true) return [];
|
|
22025
|
-
const declared = Array.isArray(im.acp.accounts) ? im.acp.accounts : [];
|
|
22026
|
-
const out = [];
|
|
22027
|
-
for (const a of declared) {
|
|
22028
|
-
if (typeof a !== "object" || a === null) continue;
|
|
22029
|
-
if (a.enabled === false) continue;
|
|
22030
|
-
const accountId = typeof a.accountId === "string" ? a.accountId.trim() : "";
|
|
22031
|
-
if (accountId === "") continue;
|
|
22032
|
-
const role = typeof a.role === "string" ? a.role.trim() : "";
|
|
22033
|
-
if (role === "") continue;
|
|
22034
|
-
const token = readAcpToken(root, accountId);
|
|
22035
|
-
if (!token) continue;
|
|
22036
|
-
out.push({
|
|
22037
|
-
root,
|
|
22038
|
-
accountId,
|
|
22039
|
-
tokenKey: acpTokenKeyFor(accountId),
|
|
22040
|
-
token,
|
|
22041
|
-
role
|
|
22042
|
-
});
|
|
22043
|
-
}
|
|
22044
|
-
return out;
|
|
22045
|
-
}
|
|
22046
|
-
/**
|
|
22047
|
-
* **让配置里声明的账户都拥有 token**(写操作;幂等;由装配层与配置变更时调用)。
|
|
22048
|
-
*
|
|
22049
|
-
* 🔴 owner 令「**随即生成**认证用的 token」的落地点。与 v1.59.0 的差别:
|
|
22050
|
-
* 那时只在**启动**时跑一次 ⇒ 加一台设备要重启;现在**任何一次判定前都可以跑**,
|
|
22051
|
-
* 且撤账户后不会被"复活"(只对**当前声明里**的账户生成)。
|
|
22052
|
-
*
|
|
22053
|
-
* @returns 本次**新生成**的账户键列表(已存在的原样返回,不轮换)
|
|
22054
|
-
*/
|
|
22055
|
-
function ensureAcpChannelTokens(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22056
|
-
const im = readImSettings(root, paths);
|
|
22057
|
-
if (im.channel !== "acp" || im.acp.enabled !== true) return [];
|
|
22058
|
-
const declared = Array.isArray(im.acp.accounts) ? im.acp.accounts : [];
|
|
22059
|
-
const created = [];
|
|
22060
|
-
for (const a of declared) {
|
|
22061
|
-
if (typeof a !== "object" || a === null) continue;
|
|
22062
|
-
if (a.enabled === false) continue;
|
|
22063
|
-
const accountId = typeof a.accountId === "string" ? a.accountId.trim() : "";
|
|
22064
|
-
if (accountId === "") continue;
|
|
22065
|
-
try {
|
|
22066
|
-
if (!readAcpToken(root, accountId)) {
|
|
22067
|
-
ensureAcpToken(root, accountId);
|
|
22068
|
-
created.push(accountId);
|
|
22069
|
-
}
|
|
22070
|
-
} catch {}
|
|
22071
|
-
}
|
|
22072
|
-
return created;
|
|
22073
|
-
}
|
|
22074
|
-
/**
|
|
22075
|
-
* 校验一枚候选 token,返回它归属的**账户**(未命中返回 null)。
|
|
22076
|
-
*
|
|
22077
|
-
* @param candidate 客户端出示的 token
|
|
22078
|
-
* @param roots 候选 CCC 根集合(由调用方枚举——本模块不认识"CCC 在哪")
|
|
22079
|
-
*/
|
|
22080
|
-
function resolveTokenAccount(candidate, roots, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22081
|
-
if (!candidate) return null;
|
|
22082
|
-
for (const root of roots) for (const acct of expandAcpAccounts(root, paths)) if (safeEqual(candidate, acct.token)) return acct;
|
|
22083
|
-
return null;
|
|
22084
|
-
}
|
|
22085
|
-
/**
|
|
22086
|
-
* 枚举**有可用 ACP 账户**的 CCC(供准入名单用)。
|
|
22087
|
-
*
|
|
22088
|
-
* 判据:`im.channel === 'acp'` ∧ `im.acp.enabled === true` ∧ **至少一个账户有 token 且配了角色**。
|
|
22089
|
-
* ⚠️ 缺一即不算(fail-closed:宁可连不上,不可放行一个半配置的通道)。
|
|
22090
|
-
*/
|
|
22091
|
-
function listAcpRoots(roots, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22092
|
-
const out = [];
|
|
22093
|
-
for (const root of roots) if (expandAcpAccounts(root, paths).length > 0) out.push(root);
|
|
22094
|
-
return out;
|
|
22095
|
-
}
|
|
22096
|
-
/** 读该 CCC 的 `im` 段(未配置 ⇒ 归一缺省;永不 undefined) */
|
|
22097
|
-
function readImSettings(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22098
|
-
try {
|
|
22099
|
-
const im = loadSerenityConfig(root, paths).im;
|
|
22100
|
-
if (!im || typeof im !== "object") return {
|
|
22101
|
-
channel: "weixin",
|
|
22102
|
-
acp: {}
|
|
22103
|
-
};
|
|
22104
|
-
return {
|
|
22105
|
-
channel: im.channel === "acp" ? "acp" : "weixin",
|
|
22106
|
-
acp: im.acp && typeof im.acp === "object" ? im.acp : {}
|
|
22107
|
-
};
|
|
22108
|
-
} catch {
|
|
22109
|
-
return {
|
|
22110
|
-
channel: "weixin",
|
|
22111
|
-
acp: {}
|
|
22112
|
-
};
|
|
22113
|
-
}
|
|
22114
|
-
}
|
|
22115
|
-
/**
|
|
22116
|
-
* 该 CCC 是否启用 ACP 入站通道。
|
|
22117
|
-
*
|
|
22118
|
-
* 🔴 **两个条件都真才算**:`im.channel === 'acp'` **且** `im.acp.enabled === true`。
|
|
22119
|
-
* 为什么双键:`channel` 表达"选了哪条路",`enabled` 表达"现在开不开" ⇒
|
|
22120
|
-
* 让 owner 能**选好 ACP 但暂时关着**(不用删配置),与既有各面的 `enabled` 范式一致。
|
|
22121
|
-
*/
|
|
22122
|
-
function acpChannelEnabled(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22123
|
-
const im = readImSettings(root, paths);
|
|
22124
|
-
return im.channel === "acp" && im.acp.enabled === true;
|
|
22125
|
-
}
|
|
22126
|
-
/**
|
|
22127
|
-
* 客户端 `session/new` 传来的 `cwd` 是否落在该 token 归属的 CCC 内。
|
|
22128
|
-
*
|
|
22129
|
-
* 🔴 **为什么必须校验**:token 定的是"哪个 CCC",而 `cwd` 由**客户端**指定。
|
|
22130
|
-
* 不校验 ⇒ 一枚 CCC A 的 token 可以把会话开到 CCC B(越权读到另一个 CCC 的上下文)。
|
|
22131
|
-
* 这与 CCC 自身的路径围墙(P3)是同一条纪律,只是发生在**接入**这一层。
|
|
22132
|
-
*
|
|
22133
|
-
* 判据用 `relative()` 而非 `startsWith`(后者会把 `/a/bc` 误判为在 `/a/b` 内)。
|
|
22134
|
-
*/
|
|
22135
|
-
function cwdInsideRoot(cwd, root) {
|
|
22136
|
-
if (!isAbsolute(cwd)) return false;
|
|
22137
|
-
const rel = relative(resolve(root), resolve(cwd));
|
|
22138
|
-
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
22139
|
-
}
|
|
22140
|
-
/** 该 CCC 的 ACP 通道对外**可见形态**(诊断面用;**绝不含 token 明文**) */
|
|
22141
|
-
function acpChannelStatus(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22142
|
-
const im = readImSettings(root, paths);
|
|
22143
|
-
const declared = Array.isArray(im.acp.accounts) ? im.acp.accounts : [];
|
|
22144
|
-
const usable = new Set(expandAcpAccounts(root, paths).map((a) => a.accountId));
|
|
22145
|
-
const accounts = [];
|
|
22146
|
-
for (const a of declared) {
|
|
22147
|
-
if (typeof a !== "object" || a === null) continue;
|
|
22148
|
-
const accountId = typeof a.accountId === "string" ? a.accountId.trim() : "";
|
|
22149
|
-
if (accountId === "") continue;
|
|
22150
|
-
const role = typeof a.role === "string" && a.role.trim() !== "" ? a.role.trim() : null;
|
|
22151
|
-
const token = readAcpToken(root, accountId);
|
|
22152
|
-
const enabled = a.enabled !== false;
|
|
22153
|
-
let blockedBy = null;
|
|
22154
|
-
if (!enabled) blockedBy = "该账户 enabled=false";
|
|
22155
|
-
else if (!role) blockedBy = "未配 role";
|
|
22156
|
-
else if (!token) blockedBy = "尚无 token(保存配置后自动生成)";
|
|
22157
|
-
accounts.push({
|
|
22158
|
-
accountId,
|
|
22159
|
-
name: typeof a.name === "string" && a.name.trim() !== "" ? a.name.trim() : null,
|
|
22160
|
-
role,
|
|
22161
|
-
enabled,
|
|
22162
|
-
hasToken: token !== null,
|
|
22163
|
-
tokenFingerprint: token ? tokenFingerprint(token) : null,
|
|
22164
|
-
mainSession: readAcpMainSession(root, accountId),
|
|
22165
|
-
usable: usable.has(accountId),
|
|
22166
|
-
blockedBy
|
|
22167
|
-
});
|
|
22168
|
-
}
|
|
22169
|
-
return {
|
|
22170
|
-
channel: im.channel,
|
|
22171
|
-
enabled: acpChannelEnabled(root, paths),
|
|
22172
|
-
accounts
|
|
22173
|
-
};
|
|
22174
|
-
}
|
|
22175
|
-
/**
|
|
22176
|
-
* 读取 serenity.json 原文(不存在 → 空对象)。
|
|
22177
|
-
*
|
|
22178
|
-
* ⚠️ 与 `weixin-route.ts` 的同名函数**刻意各写一份**:两者写的是**不同段**
|
|
22179
|
-
* (`weixin` vs `im.acp.accounts`),共用一个会在"某天只想改其中一段"时
|
|
22180
|
-
* 意外把另一段也重写一遍。同规则两份 > 一个会互相牵连的共用件。
|
|
22181
|
-
*/
|
|
22182
|
-
function readRawConfig(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22183
|
-
for (const candidate of paths) {
|
|
22184
|
-
const p = resolve(root, candidate);
|
|
22185
|
-
if (existsSync(p)) try {
|
|
22186
|
-
const v = JSON.parse(readFileSync(p, "utf-8").replace(/^\uFEFF/, ""));
|
|
22187
|
-
if (v && typeof v === "object" && !Array.isArray(v)) return v;
|
|
22188
|
-
} catch {
|
|
22189
|
-
return {};
|
|
22190
|
-
}
|
|
22191
|
-
}
|
|
22192
|
-
return {};
|
|
22193
|
-
}
|
|
22194
|
-
/**
|
|
22195
|
-
* 写回 `im.acp.accounts`(保留文件里其余所有字段)。
|
|
22196
|
-
*
|
|
22197
|
-
* 🔴 **只碰这一个键**:`im.channel` / `im.acp.enabled` 归面板总开关与人工配置,
|
|
22198
|
-
* 本函数不越权改它们(否则"加一台设备"会顺手把通道开关也改了)。
|
|
22199
|
-
*/
|
|
22200
|
-
function saveAcpAccounts(root, accounts, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22201
|
-
const target = resolve(root, paths[0] ?? ".opencode/serenity.json");
|
|
22202
|
-
const raw = readRawConfig(root, paths);
|
|
22203
|
-
const im = raw.im && typeof raw.im === "object" ? raw.im : {};
|
|
22204
|
-
const acp = im.acp && typeof im.acp === "object" ? im.acp : {};
|
|
22205
|
-
acp.accounts = accounts;
|
|
22206
|
-
im.acp = acp;
|
|
22207
|
-
raw.im = im;
|
|
22208
|
-
const dir = resolve(target, "..");
|
|
22209
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
22210
|
-
writeFileSync(target, JSON.stringify(raw, null, 2) + "\n", "utf-8");
|
|
22211
|
-
return accounts;
|
|
22212
|
-
}
|
|
22213
|
-
/**
|
|
22214
|
-
* 规范化一条账户声明(面板传入的任意形状 → 合法声明;非法 ⇒ null)。
|
|
22215
|
-
*
|
|
22216
|
-
* 🔴 返回类型里 `role` 是**必填**(不是 `ImAcpAccount` 的可选形态)——
|
|
22217
|
-
* 本函数的契约就是"要么给一个角色齐备的账户,要么给 null"。
|
|
22218
|
-
* 这样调用方**不必再判一次** `role` 有没有(那正是会漂的第二处判据)。
|
|
22219
|
-
*/
|
|
22220
|
-
function normalizeAcpAccount(input) {
|
|
22221
|
-
if (typeof input !== "object" || input === null) return null;
|
|
22222
|
-
const a = input;
|
|
22223
|
-
const accountId = typeof a.accountId === "string" ? a.accountId.trim() : "";
|
|
22224
|
-
if (accountId === "") return null;
|
|
22225
|
-
const role = typeof a.role === "string" ? a.role.trim() : "";
|
|
22226
|
-
if (role === "") return null;
|
|
22227
|
-
const out = {
|
|
22228
|
-
accountId,
|
|
22229
|
-
role
|
|
22230
|
-
};
|
|
22231
|
-
if (typeof a.name === "string" && a.name.trim() !== "") out.name = a.name.trim();
|
|
22232
|
-
if (a.enabled === false) out.enabled = false;
|
|
22233
|
-
return out;
|
|
22234
|
-
}
|
|
22235
|
-
/** 增/改一条账户(按 accountId 定位;写入后返回全表) */
|
|
22236
|
-
function upsertAcpAccount(root, account, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22237
|
-
const list = [...readImSettings(root, paths).acp.accounts ?? []];
|
|
22238
|
-
const idx = list.findIndex((a) => a.accountId === account.accountId);
|
|
22239
|
-
if (idx >= 0) list[idx] = {
|
|
22240
|
-
...list[idx],
|
|
22241
|
-
...account
|
|
22242
|
-
};
|
|
22243
|
-
else list.push(account);
|
|
22244
|
-
return saveAcpAccounts(root, list, paths);
|
|
22245
|
-
}
|
|
22246
|
-
/**
|
|
22247
|
-
* 删一条账户(**同时清掉它的 token + 主会话**,避免留死键)。
|
|
22248
|
-
*
|
|
22249
|
-
* 🔴 主会话键**必须一起清**:否则日后用同一个 `accountId` 重建账户时,它会**静默继承**
|
|
22250
|
-
* 那条旧会话 —— 而那条会话的 cwd/角色都是上一代账户的。这个失效**没有任何信号**
|
|
22251
|
-
* (列表上它看着就是一条正常会话)。
|
|
22252
|
-
*/
|
|
22253
|
-
function removeAcpAccount(root, accountId, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22254
|
-
const next = saveAcpAccounts(root, (readImSettings(root, paths).acp.accounts ?? []).filter((a) => a.accountId !== accountId), paths);
|
|
22255
|
-
for (const key of [acpTokenKeyFor(accountId), acpSessionKeyFor(accountId)]) try {
|
|
22256
|
-
unsetEntry(root, "credential", key);
|
|
22257
|
-
} catch {}
|
|
22258
|
-
return next;
|
|
22259
|
-
}
|
|
22260
|
-
//#endregion
|
|
22261
|
-
//#region src/acp-face.ts
|
|
22262
|
-
/** 面名(面宿主幂等键;与 FACE_PORTS 的 id 同名,便于对账) */
|
|
22263
|
-
const FACE_ACP = "acp";
|
|
22264
|
-
/**
|
|
22265
|
-
* 校验一次 upgrade 请求(**只读**:不改任何状态)。
|
|
22266
|
-
*
|
|
22267
|
-
* 判据链(任一环失败即拒,fail-closed):候选 token 缺失 ⇒ 不匹配任何 CCC ⇒
|
|
22268
|
-
* 该 CCC 未启用 ACP 通道 ⇒ 该 CCC 未配角色。最后一环尤其重要:**没有角色的 ACP 会话
|
|
22269
|
-
* 等于一个无身份、无约束的裸 agent** ⇒ 宁可连不上。
|
|
22270
|
-
*/
|
|
22271
|
-
function authorizeUpgrade(req) {
|
|
22272
|
-
const candidate = extractToken(req.headers, req.url);
|
|
22273
|
-
if (!candidate) return {
|
|
22274
|
-
ok: false,
|
|
22275
|
-
reason: "未携带 token(用 Authorization: Bearer <t> 或 ?token=<t>)"
|
|
22276
|
-
};
|
|
22277
|
-
const roots = knownAcpRoots();
|
|
22278
|
-
if (roots.length === 0) return {
|
|
22279
|
-
ok: false,
|
|
22280
|
-
reason: "本机没有任何启用了 ACP 通道的 CCC"
|
|
22281
|
-
};
|
|
22282
|
-
const acct = resolveTokenAccount(candidate, roots);
|
|
22283
|
-
if (!acct) return {
|
|
22284
|
-
ok: false,
|
|
22285
|
-
reason: "token 不匹配任何已声明账户的 ACP 通道"
|
|
22286
|
-
};
|
|
22287
|
-
const role = acct.role;
|
|
22288
|
-
if (!role) return {
|
|
22289
|
-
ok: false,
|
|
22290
|
-
reason: `CCC(${acct.root})账户 ${acct.accountId} 未配 im.acp.accounts[].role`
|
|
22291
|
-
};
|
|
22292
|
-
return {
|
|
22293
|
-
ok: true,
|
|
22294
|
-
root: acct.root,
|
|
22295
|
-
role,
|
|
22296
|
-
accountId: acct.accountId
|
|
22297
|
-
};
|
|
22298
|
-
}
|
|
22299
|
-
/**
|
|
22300
|
-
* CCC 根候选的来源(**只要根,不判配置**)。
|
|
22301
|
-
*
|
|
22302
|
-
* 🔴 v1.59.1 分层变更:**这份名单是"本机有哪些 CCC",不是"谁能连 ACP"**。
|
|
22303
|
-
* "谁能连"由 `expandAcpAccounts` 现算(读各 CCC 的 serenity.json + localstore)。
|
|
22304
|
-
* ⇒ 缓存失效的代价从"配错权限"降为"多看/少看一个候选根",**不再是安全问题**。
|
|
22305
|
-
*/
|
|
22306
|
-
let knownRoots = [];
|
|
22307
|
-
/**
|
|
22308
|
-
* 准入名单的现算入口(v1.59.1)。
|
|
22309
|
-
*
|
|
22310
|
-
* 顺序:**先用已知根**(免 async)→ 逐个 `expandAcpAccounts` 现读配置。
|
|
22311
|
-
* 未知根时退回上次枚举的结果(可能稍旧,但只影响"会不会去问它",不影响是否放行)。
|
|
22312
|
-
*
|
|
22313
|
-
* ⚠️ 这里是**同步**的(upgrade 回调用)⇒ 不 await、不扫盘。
|
|
22314
|
-
*/
|
|
22315
|
-
function knownAcpRoots() {
|
|
22316
|
-
return listAcpRoots(knownRoots);
|
|
22317
|
-
}
|
|
22318
|
-
/**
|
|
22319
|
-
* 刷新"本机有哪些 CCC"(async,枚举宿主三来源)。
|
|
22320
|
-
*
|
|
22321
|
-
* 🔴 **失败必须响亮**:v1.59.0 这里是 `void refresh(...)` 且没有 catch ——
|
|
22322
|
-
* 枚举失败会让名单清空 ⇒ **所有连接一律 401**,而现场只有一行 stdout 看不到的日志。
|
|
22323
|
-
* 这正是本轨记过的「调用没报错 ≠ 已验证」。
|
|
22324
|
-
*
|
|
22325
|
-
* @returns 本次枚举到的根数(0 = 一个都没发现,日志会说为什么)
|
|
22326
|
-
*/
|
|
22327
|
-
async function refreshAcpRoots(ctx) {
|
|
22328
|
-
try {
|
|
22329
|
-
knownRoots = (await listCccs(ctx)).map((e) => e.root);
|
|
22330
|
-
let created = 0;
|
|
22331
|
-
for (const root of knownRoots) try {
|
|
22332
|
-
created += ensureAcpChannelTokens(root).length;
|
|
22333
|
-
} catch (err) {
|
|
22334
|
-
console.warn(`[serenity-hooks] 面 E:为 CCC(${root})生成 token 失败: ${String(err?.message ?? err)}`);
|
|
22335
|
-
}
|
|
22336
|
-
if (created > 0) console.log(`[serenity-hooks] ✓ 面 E:已为 ${created} 个 ACP 账户生成 token`);
|
|
22337
|
-
return knownRoots.length;
|
|
22338
|
-
} catch (err) {
|
|
22339
|
-
knownRoots = [];
|
|
22340
|
-
const msg = `面 E:CCC 枚举失败(准入名单将为空,所有连接会被拒): ${String(err?.message ?? err)}`;
|
|
22341
|
-
console.warn(`[serenity-hooks] ${msg}`);
|
|
22342
|
-
state.lastErrors.push({
|
|
22343
|
-
at: Date.now(),
|
|
22344
|
-
applied: false,
|
|
22345
|
-
error: msg
|
|
22346
|
-
});
|
|
22347
|
-
if (state.lastErrors.length > MAX_ERR_KEEP) state.lastErrors.shift();
|
|
22348
|
-
return 0;
|
|
22349
|
-
}
|
|
22350
|
-
}
|
|
22351
|
-
/**
|
|
22352
|
-
* 把**本连接归属的 CCC 角色**套到该连接创建的 agent 上(v1.59.0)。
|
|
22353
|
-
*
|
|
22354
|
-
* ## 🔴 为什么是"认领"而不是"直接挂"
|
|
22355
|
-
*
|
|
22356
|
-
* 理想形态是:在本连接的 scoped fiber 里监听 `session/created`,**只**收到本连接建的会话。
|
|
22357
|
-
* 但那条路依赖一个**我没有实测过的**引擎行为 —— cordis 的 `EventsService.dispatch()` 只在
|
|
22358
|
-
* **派发方设了 `Context.filter`** 时才按 ctx 过滤(读 `@deepseek-ai/cordis/src/events.ts`
|
|
22359
|
-
* 的 `.filter(hook => hook.global || !filter || filter.call(thisArg, hook.ctx))`)。
|
|
22360
|
-
* ⇒ 若宿主派发 `session/created` 时**没设**过滤器,本监听器会**收到全进程**的会话
|
|
22361
|
-
* (网页端、别的轨迹、别的连接),**套错角色**。
|
|
22362
|
-
*
|
|
22363
|
-
* 🔴 **D119 纪律**:不许照源码的"意图"断言引擎行为 ⇒ 本实现**不赌**那条语义,
|
|
22364
|
-
* 改用**认领制**(两条过滤都可以自证,不依赖 filter):
|
|
22365
|
-
* ① **cwd 必须落在本连接归属的 CCC 内**(token 已定 CCC ⇒ 越界会话直接跳过);
|
|
22366
|
-
* ② **同一会话只被认领一次**(`claimed` 集合;先到先得 ⇒ 不会被另一条连接的监听器抢走)。
|
|
22367
|
-
*
|
|
22368
|
-
* ⚠️ **残余风险(如实标注)**:若**同一 CCC** 同时有网页端会话与 ACP 会话在创建,
|
|
22369
|
-
* 本函数可能把网页端那个会话也认领了(两者 cwd 都在该 CCC 内)。⇒ 判据是"cwd + 先到先得",
|
|
22370
|
-
* **不是"确属本连接"**。⚠️ **这一格必须由探针实测**(见 REBUILD-TODO 的待办);
|
|
22371
|
-
* 未实测前**不得**声称"只套本连接的会话"。
|
|
22372
|
-
*
|
|
22373
|
-
* 失败语义:全程不抛(本面是可选面)——角色挂不上只记日志,不阻断 ACP 接线。
|
|
22374
|
-
*/
|
|
22375
|
-
function bindRoleToConnScopedAgent(scoped, root, roleName) {
|
|
22376
|
-
try {
|
|
22377
|
-
const role = readSkiffRoles(root).get(roleName);
|
|
22378
|
-
if (!role) {
|
|
22379
|
-
console.warn(`[serenity-hooks] 面 E:CCC(${root})未定义 skiff 角色 "${roleName}" ⇒ 本连接不注入角色`);
|
|
22380
|
-
return;
|
|
22381
|
-
}
|
|
22382
|
-
const rolePrompt = createRolePromptReader(root, role);
|
|
22383
|
-
const claimed = /* @__PURE__ */ new Set();
|
|
22384
|
-
const onCreated = (session) => {
|
|
22385
|
-
try {
|
|
22386
|
-
const id = session?.header?.id;
|
|
22387
|
-
const cwd = session?.header?.cwd;
|
|
22388
|
-
if (!id || !cwd) return;
|
|
22389
|
-
if (claimed.has(id)) return;
|
|
22390
|
-
if (!cwdInsideRoot(cwd, root)) return;
|
|
22391
|
-
const target = resolveAgentById(scoped, id);
|
|
22392
|
-
if (!target) return;
|
|
22393
|
-
target.ctx.systemPrompt.section({
|
|
22394
|
-
name: "serenity-skiff",
|
|
22395
|
-
order: -60,
|
|
22396
|
-
text: () => [buildSkiffBasePrompt(roleName, role), rolePrompt()].filter(Boolean).join("\n")
|
|
22397
|
-
});
|
|
22398
|
-
claimed.add(id);
|
|
22399
|
-
console.log(`[serenity-hooks] ✓ 面 E:会话 ${id} 已绑定角色 "${roleName}"(CCC=${root})`);
|
|
22400
|
-
} catch (err) {
|
|
22401
|
-
console.warn(`[serenity-hooks] 面 E:角色注入失败(不影响会话): ${String(err?.message ?? err)}`);
|
|
22402
|
-
}
|
|
22403
|
-
};
|
|
22404
|
-
for (const eventName of ["session/created", "agent/created"]) try {
|
|
22405
|
-
scoped.on(eventName, onCreated);
|
|
22406
|
-
} catch {}
|
|
22407
|
-
} catch (err) {
|
|
22408
|
-
console.warn(`[serenity-hooks] 面 E:角色绑定装配失败: ${String(err?.message ?? err)}`);
|
|
22409
|
-
}
|
|
22410
|
-
}
|
|
22411
|
-
/** 按会话 id 取 live agent(只读;取不到返回 undefined) */
|
|
22412
|
-
function resolveAgentById(scoped, sessionId) {
|
|
22413
|
-
try {
|
|
22414
|
-
const agent = hostInjected(scoped, "agents")?.get?.(sessionId);
|
|
22415
|
-
if (!agent?.ctx?.systemPrompt || typeof agent.ctx.systemPrompt.section !== "function") return void 0;
|
|
22416
|
-
return agent;
|
|
22417
|
-
} catch {
|
|
22418
|
-
return;
|
|
22419
|
-
}
|
|
22420
|
-
}
|
|
22421
|
-
/** 本面绑定的地址(取自集中端口表 —— **单一真相源**,不在本文件硬写) */
|
|
22422
|
-
const ACP_HOST = FACE_PORTS.acp.host ?? "127.0.0.1";
|
|
22423
|
-
/**
|
|
22424
|
-
* `dsh-acp` 需要的服务(**替它声明**,见文件头坑 ①)。
|
|
22425
|
-
*
|
|
22426
|
-
* 逐字出处 = `dsh-acp/lib/index.js` 的 `const inject = ["agents","llm","sessionPersistence","sessions"]`。
|
|
22427
|
-
* ⚠️ **少一个就在它内部抛错**(实测那条是 `sessionPersistence`)⇒ 这里是**照抄它的声明**,
|
|
22428
|
-
* 不是"我们觉得它需要什么"。⇒ 它升版改了 `inject`,这里要跟着改(列为**跨版本注意点**)。
|
|
22429
|
-
*/
|
|
22430
|
-
const ACP_REQUIRED_SERVICES = [
|
|
22431
|
-
"agents",
|
|
22432
|
-
"llm",
|
|
22433
|
-
"sessionPersistence",
|
|
22434
|
-
"sessions"
|
|
22435
|
-
];
|
|
22436
|
-
function acpFaceActive() {
|
|
22437
|
-
return faceActive(FACE_ACP);
|
|
22438
|
-
}
|
|
22439
|
-
/**
|
|
22440
|
-
* 面是否在监听(意图读取)。
|
|
22441
|
-
* ⚠️ 与 `startFace` 的关系同其余各面:**启动本身不读它**(`startFace` 是机械入口),
|
|
22442
|
-
* 由装配层用它决定"起还是停"。
|
|
22443
|
-
*/
|
|
22444
|
-
function acpFaceEnabled() {
|
|
22445
|
-
return readSimpleSettings().acpFaceEnabled;
|
|
22446
|
-
}
|
|
22447
|
-
/**
|
|
22448
|
-
* 把客户端给的 `cwd` 归一化成宿主要求的**绝对路径**(v1.59.1)。
|
|
22449
|
-
*
|
|
22450
|
-
* ## 为什么需要
|
|
22451
|
-
* 宿主 `dsh-acp` 在每次 `session/new` 都有这一行(`lib/index.js` 逐字):
|
|
22452
|
-
* ```js
|
|
22453
|
-
* if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
|
|
22454
|
-
* ```
|
|
22455
|
-
* 而**连接归属哪个 CCC 已由 token 决定**(服务端权威)⇒ 让用户再手打一遍绝对路径
|
|
22456
|
-
* 是**同一个信息说两遍**,且那第二遍正是最容易打错的一遍。
|
|
22457
|
-
*
|
|
22458
|
-
* ## 规则(唯一一条)
|
|
22459
|
-
* **非绝对路径 ⇒ 一律解释为「本连接归属 CCC 的根」**。
|
|
22460
|
-
*
|
|
22461
|
-
* | 客户端填 | 送宿主 |
|
|
22462
|
-
* |---|---|
|
|
22463
|
-
* | `serenity` / `a` / `.` / 空 | `/…/home-serenity`(该 token 的 CCC 根) |
|
|
22464
|
-
* | `/…/home-serenity`(已绝对且在根内) | 原样 |
|
|
22465
|
-
* | `/…/home-serenity/sub` | 原样(仍在根内) |
|
|
22466
|
-
* | `/etc`(绝对但在根外) | **原样透传,交给 `cwdInsideRoot` 拒** |
|
|
22467
|
-
*
|
|
22468
|
-
* 🔴 **最后一行为什么不在这里直接拒**:越权判据必须**只有一处**(`cwdInsideRoot`,
|
|
22469
|
-
* 它也管着角色认领),在这里再判一次就是**两份会漂的实现**。归一化只负责"把非绝对变成绝对"。
|
|
22470
|
-
*
|
|
22471
|
-
* @param raw 客户端原样给的 `cwd`(可能是 `undefined`)
|
|
22472
|
-
* @param cccRoot 本连接归属的 CCC 根
|
|
22473
|
-
*/
|
|
22474
|
-
function normalizeClientCwd(raw, cccRoot) {
|
|
22475
|
-
const value = typeof raw === "string" ? raw.trim() : "";
|
|
22476
|
-
if (value === "" || !isAbsolute(value)) return cccRoot;
|
|
22477
|
-
return value;
|
|
22478
|
-
}
|
|
22479
|
-
/**
|
|
22480
|
-
* 把一条裸 WS socket 适配成 ACP `Stream`。
|
|
22481
|
-
*
|
|
22482
|
-
* 🔴 与官方 `ndJsonStream` **同语义**:我们自己做「消息 ↔ 文本帧」的分帧(按行)。
|
|
22483
|
-
* 实测注意(P0′):`dsh-acp` 写出的每条消息**自带换行**,客户端同样 ⇒ 按行切分即正确。
|
|
22484
|
-
* ⚠️ **空行要丢**;**非 JSON 帧要容忍**(桥/客户端可能插入控制帧)。
|
|
22485
|
-
*
|
|
22486
|
-
* 🔴 v1.59.1:**入站消息在这里做一次 `cwd` 归一化**(见 {@link normalizeClientCwd})。
|
|
22487
|
-
* 为什么是这里:`session/new` 与 `session/resume` 都带 `cwd`,而这是**唯一**能同时
|
|
22488
|
-
* 拦到两者、且属于我们自己的缝(再往下就进 `dsh-acp` 了,那里改了就是改宿主)。
|
|
22489
|
-
*
|
|
22490
|
-
* 🔴 v1.59.2:这条缝同时承担 **owner「每账户固定一条长期对话」** 的双向改写 ——
|
|
22491
|
-
* 入站 `recordAcpPending`(记 id→method)+ 出站 `rewriteAcpResponse`(收窄 list / 记 new)。
|
|
22492
|
-
* ⚠️ 两件事都必须在**同一个** `ws` 上做:`pending` 是**每条连接一份**的,
|
|
22493
|
-
* 拆到两处会让它失去"这条连接上请求与响应配对"这个唯一用途。
|
|
22494
|
-
*
|
|
22495
|
-
* @param cccRoot 本连接归属的 CCC 根(token 反查所得)
|
|
22496
|
-
* @param accountId 本连接归属的账户键(主会话键由它派生)
|
|
22497
|
-
*/
|
|
22498
|
-
function wsToAcpStream(ws, onWarn, cccRoot, accountId) {
|
|
22499
|
-
const pin = createAcpSessionPin(cccRoot, accountId);
|
|
22500
|
-
return {
|
|
22501
|
-
readable: new ReadableStream({ start(controller) {
|
|
22502
|
-
let buf = "";
|
|
22503
|
-
ws.on("message", (data) => {
|
|
22504
|
-
buf += typeof data === "string" ? data : String(data);
|
|
22505
|
-
let i;
|
|
22506
|
-
while ((i = buf.indexOf("\n")) >= 0) {
|
|
22507
|
-
const line = buf.slice(0, i);
|
|
22508
|
-
buf = buf.slice(i + 1);
|
|
22509
|
-
if (!line.trim()) continue;
|
|
22510
|
-
let msg;
|
|
22511
|
-
try {
|
|
22512
|
-
msg = JSON.parse(line);
|
|
22513
|
-
} catch {
|
|
22514
|
-
onWarn(line.slice(0, 120));
|
|
22515
|
-
continue;
|
|
22516
|
-
}
|
|
22517
|
-
recordAcpPending(pin, msg);
|
|
22518
|
-
controller.enqueue(normalizeInboundCwd(msg, cccRoot));
|
|
22519
|
-
}
|
|
22520
|
-
});
|
|
22521
|
-
ws.on("close", () => {
|
|
22522
|
-
try {
|
|
22523
|
-
controller.close();
|
|
22524
|
-
} catch {}
|
|
22525
|
-
});
|
|
22526
|
-
ws.on("error", () => {
|
|
22527
|
-
try {
|
|
22528
|
-
controller.close();
|
|
22529
|
-
} catch {}
|
|
22530
|
-
});
|
|
22531
|
-
} }),
|
|
22532
|
-
writable: new WritableStream({ write(msg) {
|
|
22533
|
-
ws.send(`${JSON.stringify(rewriteAcpResponse(pin, msg))}\n`);
|
|
22534
|
-
} })
|
|
22535
|
-
};
|
|
22536
|
-
}
|
|
22537
|
-
/**
|
|
22538
|
-
* 需要把 `cwd` 钉到本连接 CCC 根的方法(**必须逐个列出**)。
|
|
22539
|
-
*
|
|
22540
|
-
* 🔴 **v1.59.1 的第一版判据错了**:只判「params 里有没有 `cwd`」⇒ 漏掉 `session/list`。
|
|
22541
|
-
* 宿主 `listSessions` 的过滤是
|
|
22542
|
-
* `if (params.cwd !== void 0 && !sameDirectory(header.cwd, params.cwd)) return`
|
|
22543
|
-
* —— **不给 cwd 就返回全机器所有会话**。实测后果:手机 App 列表里冒出
|
|
22544
|
-
* 23 条别的 CCC 的会话,全部显示为 `UNKNOWN` / `New Chat`
|
|
22545
|
-
* (owner:「**我需要是指定CCC的,不然没办法用**」)。
|
|
22546
|
-
* ⇒ 🆕 **判据:凡"应当被 CCC 收窄"的读接口,都必须把 `cwd` 钉成 CCC 根,不能依赖客户端传** ——
|
|
22547
|
-
* token 已经定了 CCC,让客户端再声明一次既冗余、又可以被静默漏掉。
|
|
22548
|
-
*/
|
|
22549
|
-
const CWD_PINNED_METHODS = /* @__PURE__ */ new Set([
|
|
22550
|
-
"session/new",
|
|
22551
|
-
"session/resume",
|
|
22552
|
-
"session/list"
|
|
22553
|
-
]);
|
|
22554
|
-
/**
|
|
22555
|
-
* 把一条入站消息的 `cwd` 归一化(**含"没给就补上"**)。
|
|
22556
|
-
*
|
|
22557
|
-
* | 情形 | 结果 |
|
|
22558
|
-
* |---|---|
|
|
22559
|
-
* | 方法不在 {@link CWD_PINNED_METHODS} | 原样(不碰别的消息) |
|
|
22560
|
-
* | 给了非绝对路径(`serenity` / `a` / 空串) | → 本连接 CCC 根 |
|
|
22561
|
-
* | 给了绝对路径 | 原样(越界由 `cwdInsideRoot` 拒) |
|
|
22562
|
-
* | **压根没给**(`session/list` 常见) | **→ 本连接 CCC 根**(否则列出全机器) |
|
|
22563
|
-
*/
|
|
22564
|
-
function normalizeInboundCwd(msg, cccRoot) {
|
|
22565
|
-
if (typeof msg !== "object" || msg === null) return msg;
|
|
22566
|
-
const m = msg;
|
|
22567
|
-
if (typeof m.method !== "string" || !CWD_PINNED_METHODS.has(m.method)) return msg;
|
|
22568
|
-
const params = typeof m.params === "object" && m.params !== null ? m.params : {};
|
|
22569
|
-
return {
|
|
22570
|
-
...msg,
|
|
22571
|
-
params: {
|
|
22572
|
-
...params,
|
|
22573
|
-
cwd: normalizeClientCwd(params.cwd, cccRoot)
|
|
22574
|
-
}
|
|
22575
|
-
};
|
|
22576
|
-
}
|
|
22577
|
-
/** 请求 id 的 Map 键(区分 `1` 与 `"1"` —— JSON-RPC 允许两者,混用也不该串号) */
|
|
22578
|
-
function requestIdKey(id) {
|
|
22579
|
-
return `${typeof id}:${String(id)}`;
|
|
22580
|
-
}
|
|
22581
|
-
/** 记账上限(防"客户端发一堆请求又从不收响应"把内存撑大) */
|
|
22582
|
-
const MAX_PENDING = 256;
|
|
22583
|
-
/** 建一条连接的钉住状态 */
|
|
22584
|
-
function createAcpSessionPin(root, accountId) {
|
|
22585
|
-
return {
|
|
22586
|
-
root,
|
|
22587
|
-
accountId,
|
|
22588
|
-
pending: /* @__PURE__ */ new Map()
|
|
22589
|
-
};
|
|
22590
|
-
}
|
|
22591
|
-
/**
|
|
22592
|
-
* 入站记账:把"这条请求的方法"记住,供出站按 `id` 反查。
|
|
22593
|
-
*
|
|
22594
|
-
* 判据:**既要有 `id`,又要有 `method`** —— 只满足一个的是响应(`id` 无 `method`)
|
|
22595
|
-
* 或通知(`method` 无 `id`)⇒ 与我们无关,不记。
|
|
22596
|
-
*/
|
|
22597
|
-
function recordAcpPending(pin, msg) {
|
|
22598
|
-
if (typeof msg !== "object" || msg === null) return;
|
|
22599
|
-
const m = msg;
|
|
22600
|
-
if (m.id === void 0 || m.id === null) return;
|
|
22601
|
-
if (typeof m.method !== "string") return;
|
|
22602
|
-
const key = requestIdKey(m.id);
|
|
22603
|
-
pin.pending.set(key, m.method);
|
|
22604
|
-
while (pin.pending.size > MAX_PENDING) {
|
|
22605
|
-
const oldest = pin.pending.keys().next();
|
|
22606
|
-
if (oldest.done) break;
|
|
22607
|
-
pin.pending.delete(oldest.value);
|
|
22608
|
-
}
|
|
22609
|
-
}
|
|
22610
|
-
/**
|
|
22611
|
-
* 出站改写(本功能的落点)。返回**要发给客户端的那条消息**。
|
|
22612
|
-
*
|
|
22613
|
-
* | 原响应 | 改写后 |
|
|
22614
|
-
* |---|---|
|
|
22615
|
-
* | `session/list` | `{sessions:[该账户那一条]}`;**没有主会话 ⇒ `{sessions:[]}`** |
|
|
22616
|
-
* | `session/new` | 原样,**同时**把 `result.sessionId` 记为该账户的主会话 |
|
|
22617
|
-
* | 其余(含所有 error 响应、所有通知) | 原样 |
|
|
22618
|
-
*
|
|
22619
|
-
* 🔴 **没有主会话时回空数组,绝不回退成全量列表** —— 退回去正是 owner 说
|
|
22620
|
-
* 「23 个对我没什么用」的那个东西。空数组会让 App 引导用户新建,而新建即建立主会话。
|
|
22621
|
-
*
|
|
22622
|
-
* 🔴 `nextCursor` **一律丢掉**:收窄到一条之后,分页游标失去意义;
|
|
22623
|
-
* 留着它会让客户端继续翻页(而后续页仍会被收窄成同一条 ⇒ 前端可能出现重复项)。
|
|
22624
|
-
*/
|
|
22625
|
-
function rewriteAcpResponse(pin, msg) {
|
|
22626
|
-
if (typeof msg !== "object" || msg === null) return msg;
|
|
22627
|
-
const m = msg;
|
|
22628
|
-
if (m.method !== void 0) return msg;
|
|
22629
|
-
const method = m.id === void 0 || m.id === null ? void 0 : pin.pending.get(requestIdKey(m.id));
|
|
22630
|
-
if (method === void 0) return msg;
|
|
22631
|
-
if (m.error !== void 0) return msg;
|
|
22632
|
-
if (method === "session/new") {
|
|
22633
|
-
const sessionId = m.result?.sessionId;
|
|
22634
|
-
if (typeof sessionId === "string" && sessionId.trim() !== "") try {
|
|
22635
|
-
writeAcpMainSession(pin.root, pin.accountId, sessionId);
|
|
22636
|
-
} catch (err) {
|
|
22637
|
-
console.warn(`[serenity-hooks] ⚠ 面 E:记录主会话失败(CCC=${pin.root} 账户=${pin.accountId}): ${String(err?.message ?? err)}`);
|
|
22638
|
-
}
|
|
22639
|
-
return msg;
|
|
22640
|
-
}
|
|
22641
|
-
if (method === "session/list") {
|
|
22642
|
-
const pinned = readAcpMainSession(pin.root, pin.accountId);
|
|
22643
|
-
if (!pinned) return {
|
|
22644
|
-
...m,
|
|
22645
|
-
result: { sessions: [] }
|
|
22646
|
-
};
|
|
22647
|
-
const list = m.result?.sessions;
|
|
22648
|
-
const hit = Array.isArray(list) ? list.find((s) => s?.sessionId === pinned) : void 0;
|
|
22649
|
-
const sessions = hit !== void 0 ? [hit] : [{
|
|
22650
|
-
sessionId: pinned,
|
|
22651
|
-
cwd: pin.root
|
|
22652
|
-
}];
|
|
22653
|
-
return {
|
|
22654
|
-
...m,
|
|
22655
|
-
result: { sessions }
|
|
22656
|
-
};
|
|
22657
|
-
}
|
|
22658
|
-
return msg;
|
|
22659
|
-
}
|
|
22660
|
-
function newState() {
|
|
22661
|
-
return {
|
|
22662
|
-
handle: null,
|
|
22663
|
-
wss: null,
|
|
22664
|
-
connections: 0,
|
|
22665
|
-
applied: 0,
|
|
22666
|
-
rejected: 0,
|
|
22667
|
-
lastErrors: [],
|
|
22668
|
-
disposers: []
|
|
22669
|
-
};
|
|
22670
|
-
}
|
|
22671
|
-
/** 当前面状态(供 `status()` / 诊断读;无监听时 `handle` 为 null) */
|
|
22672
|
-
let state = newState();
|
|
22673
|
-
/** 保留最近若干条连接错误(诊断窗口;不无限增长) */
|
|
22674
|
-
const MAX_ERR_KEEP = 8;
|
|
22675
|
-
/**
|
|
22676
|
-
* 启动面 E(幂等;重复调用只起一次)。
|
|
22677
|
-
*
|
|
22678
|
-
* 🔴 **失败语义**:本面**默认关**且是**可选面** ⇒ 起不来**不得**影响插件其余部分
|
|
22679
|
-
* (调用方负责 catch;本函数把失败原样抛出,由装配层记日志)。
|
|
22680
|
-
*/
|
|
22681
|
-
async function startAcpFace(ctx) {
|
|
22682
|
-
const port = readSimpleSettings().acpFacePort;
|
|
22683
|
-
let dshAcp;
|
|
22684
|
-
let wsMod;
|
|
22685
|
-
try {
|
|
22686
|
-
dshAcp = await import("@deepseek-ai/dsh-acp");
|
|
22687
|
-
wsMod = await import("ws");
|
|
22688
|
-
} catch (err) {
|
|
22689
|
-
const msg = String(err?.message ?? err);
|
|
22690
|
-
throw new Error(`面 E 依赖不可用(${msg})—— 需要宿主提供 @deepseek-ai/dsh-acp,且本插件能 resolve 到 ws`);
|
|
22691
|
-
}
|
|
22692
|
-
if (typeof dshAcp.apply !== "function") throw new Error("面 E:@deepseek-ai/dsh-acp 未导出 apply(版本不匹配?)");
|
|
22693
|
-
const wss = new wsMod.WebSocketServer({ noServer: true });
|
|
22694
|
-
const local = newState();
|
|
22695
|
-
local.wss = wss;
|
|
22696
|
-
const handle = await startFace({
|
|
22697
|
-
name: FACE_ACP,
|
|
22698
|
-
port,
|
|
22699
|
-
host: ACP_HOST,
|
|
22700
|
-
enabled: () => readSimpleSettings().acpFaceEnabled,
|
|
22701
|
-
handler: (_req, res) => {
|
|
22702
|
-
res.writeHead(426, {
|
|
22703
|
-
"content-type": "text/plain; charset=utf-8",
|
|
22704
|
-
"upgrade": "websocket",
|
|
22705
|
-
"cache-control": "no-store"
|
|
22706
|
-
});
|
|
22707
|
-
res.end("This is an ACP (Agent Client Protocol) endpoint over WebSocket. Use an ACP client.\n");
|
|
22708
|
-
}
|
|
22709
|
-
});
|
|
22710
|
-
local.handle = handle;
|
|
22711
|
-
handle.server.on("upgrade", (req, socket, head) => {
|
|
22712
|
-
local.connections += 1;
|
|
22713
|
-
const verdict = authorizeUpgrade(req);
|
|
22714
|
-
if (!verdict.ok) {
|
|
22715
|
-
local.rejected += 1;
|
|
22716
|
-
local.lastErrors.push({
|
|
22717
|
-
at: Date.now(),
|
|
22718
|
-
applied: false,
|
|
22719
|
-
error: `auth: ${verdict.reason}`
|
|
22720
|
-
});
|
|
22721
|
-
if (local.lastErrors.length > MAX_ERR_KEEP) local.lastErrors.shift();
|
|
22722
|
-
try {
|
|
22723
|
-
socket.write(`HTTP/1.1 401 Unauthorized\r
|
|
22724
|
-
content-type: text/plain; charset=utf-8\r
|
|
22725
|
-
connection: close\r
|
|
22726
|
-
\r
|
|
22727
|
-
ACP 面:认证失败(${verdict.reason})。请在该 CCC 的 localstore 生成并使用 IM_ACP_TOKEN。\n`);
|
|
22728
|
-
} catch {}
|
|
22729
|
-
try {
|
|
22730
|
-
socket.destroy();
|
|
22731
|
-
} catch {}
|
|
22732
|
-
console.warn(`[serenity-hooks] ✗ 面 E:拒绝一条连接(${verdict.reason})`);
|
|
22733
|
-
return;
|
|
22734
|
-
}
|
|
22735
|
-
try {
|
|
22736
|
-
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
22737
|
-
try {
|
|
22738
|
-
const connRoot = verdict.root;
|
|
22739
|
-
const connRole = verdict.role;
|
|
22740
|
-
const connAccount = verdict.accountId;
|
|
22741
|
-
const stream = wsToAcpStream(ws, (line) => {
|
|
22742
|
-
console.log(`[serenity-hooks] 面 E:丢弃一条非 JSON 帧(前 120 字): ${line}`);
|
|
22743
|
-
}, connRoot, connAccount);
|
|
22744
|
-
const fiber = ctx.plugin({
|
|
22745
|
-
inject: ACP_REQUIRED_SERVICES,
|
|
22746
|
-
apply: (scoped) => {
|
|
22747
|
-
const sel = resolveAcpSelection(scoped);
|
|
22748
|
-
bindRoleToConnScopedAgent(scoped, connRoot, connRole);
|
|
22749
|
-
dshAcp.apply(scoped, {
|
|
22750
|
-
stream,
|
|
22751
|
-
provider: sel.provider,
|
|
22752
|
-
model: sel.model
|
|
22753
|
-
});
|
|
22754
|
-
}
|
|
22755
|
-
});
|
|
22756
|
-
local.disposers.push(() => {
|
|
22757
|
-
try {
|
|
22758
|
-
fiber?.dispose?.();
|
|
22759
|
-
} catch {}
|
|
22760
|
-
});
|
|
22761
|
-
local.applied += 1;
|
|
22762
|
-
} catch (err) {
|
|
22763
|
-
const msg = String(err?.message ?? err).slice(0, 200);
|
|
22764
|
-
local.lastErrors.push({
|
|
22765
|
-
at: Date.now(),
|
|
22766
|
-
applied: false,
|
|
22767
|
-
error: msg
|
|
22768
|
-
});
|
|
22769
|
-
if (local.lastErrors.length > MAX_ERR_KEEP) local.lastErrors.shift();
|
|
22770
|
-
console.warn(`[serenity-hooks] ✗ 面 E:dsh-acp 接线失败: ${msg}`);
|
|
22771
|
-
try {
|
|
22772
|
-
ws.close(1011, "acp bridge failed");
|
|
22773
|
-
} catch {}
|
|
22774
|
-
}
|
|
22775
|
-
});
|
|
22776
|
-
} catch (err) {
|
|
22777
|
-
console.warn(`[serenity-hooks] ✗ 面 E:WebSocket 握手失败: ${String(err?.message ?? err)}`);
|
|
22778
|
-
try {
|
|
22779
|
-
socket.destroy();
|
|
22780
|
-
} catch {}
|
|
22781
|
-
}
|
|
22782
|
-
});
|
|
22783
|
-
state = local;
|
|
22784
|
-
console.log(`[serenity-hooks] ✓ 面 E(官方 ACP):ws://${ACP_HOST}:${port} ⚠️ 协议无认证,靠外部 Access 兜底`);
|
|
22785
|
-
return handle;
|
|
22786
|
-
}
|
|
22787
|
-
/**
|
|
22788
|
-
* 停面 E(幂等)。
|
|
22789
|
-
*
|
|
22790
|
-
* ⚠️ **与其余各面同款注意**:`stopFace` **不用** `closeAllConnections()`(那会掐断在飞的请求);
|
|
22791
|
-
* WS 连接由 `wss.close()` 与 socket 自身生命周期收束。
|
|
22792
|
-
*/
|
|
22793
|
-
function stopAcpFace() {
|
|
22794
|
-
const wss = state.wss;
|
|
22795
|
-
if (wss) try {
|
|
22796
|
-
wss.close();
|
|
22797
|
-
} catch {}
|
|
22798
|
-
state.disposers = [];
|
|
22799
|
-
state.wss = null;
|
|
22800
|
-
state.handle = null;
|
|
22801
|
-
const stopped = stopFace(FACE_ACP);
|
|
22802
|
-
if (stopped) console.log("[serenity-hooks] face acp: 已停止");
|
|
22803
|
-
return stopped;
|
|
22804
|
-
}
|
|
22805
|
-
/** ACP 会话用的 provider(取 CCC 现有路由;留空 = 由宿主/请求监听器决定) */
|
|
22806
|
-
function readAcpProvider() {
|
|
22807
|
-
return process.env.SERENITY_ACP_PROVIDER || void 0;
|
|
22808
|
-
}
|
|
22809
|
-
/** ACP 会话用的 model(同上) */
|
|
22810
|
-
function readAcpModel() {
|
|
22811
|
-
return process.env.SERENITY_ACP_MODEL || void 0;
|
|
22812
|
-
}
|
|
22813
|
-
/**
|
|
22814
|
-
* 解析 ACP 会话该用的 `{provider, model}`。
|
|
22815
|
-
*
|
|
22816
|
-
* ## 🔴 为什么必须有这个函数(2026-10-16 容器实测抓出的**真缺陷**)
|
|
22817
|
-
*
|
|
22818
|
-
* 早先我们是**裸传** `provider: readAcpProvider(), model: readAcpModel()` —— 而它们读的是
|
|
22819
|
-
* **环境变量**,默认**未设** ⇒ 传下去就是 `undefined`。后果**不是"用默认模型"**:
|
|
22820
|
-
* 上游 `dsh-acp` 的 `agentOptions()`(`lib/index.js:1356-1361`)**只在字段非 undefined 时才带上**:
|
|
22821
|
-
* ```js
|
|
22822
|
-
* ...config.provider !== void 0 ? { provider: config.provider } : {},
|
|
22823
|
-
* ...config.model !== void 0 ? { model: config.model } : {}
|
|
22824
|
-
* ```
|
|
22825
|
-
* ⇒ **我们传 undefined,agent 就真的没有模型** ⇒ 第一条 `session/prompt` 必失败,逐字报:
|
|
22826
|
-
* `Internal error: turn failed: prompt variable "{{model}}" has no value for this assembly
|
|
22827
|
-
* (section "deployment:persona-prefix")`。
|
|
22828
|
-
*
|
|
22829
|
-
* ⚠️ **网页端不中招**(它经 `agent-default-model` 层拿到默认值)⇒ **只有 ACP 这条入口坏** ——
|
|
22830
|
-
* 也就是说:**手机上第一条消息必然失败,而本机怎么试都正常**。
|
|
22831
|
-
* 🔴 这个缺陷 **typecheck / 2812 条单测 / deploy 预检 全绿**(它是运行时合成形态,
|
|
22832
|
-
* 静态面结构性地看不见)⇒ **只有"容器 + 真模型"那一跑能抓到它**。
|
|
22833
|
-
*
|
|
22834
|
-
* ## 修法 = **读宿主自己的默认模型服务**(不另造真相源)
|
|
22835
|
-
*
|
|
22836
|
-
* 宿主暴露服务 `ctx.agentDefaultModel.currentSelection()`,其契约逐字写着
|
|
22837
|
-
* *"Default model selection for Agents created **without an explicit model**"*
|
|
22838
|
-
* (`dsh-agent-default-model/lib/types/index.d.ts`)—— 这正是"没显式指定时该用什么"的**权威出处**。
|
|
22839
|
-
* ⇒ 我们只是**把它转交给 ACP**,不引入第二套缺省。
|
|
22840
|
-
*
|
|
22841
|
-
* 优先级(**显式 > 宿主默认**):`SERENITY_ACP_PROVIDER/MODEL` 环境变量(调试用)> 宿主默认。
|
|
22842
|
-
* ⚠️ **一处如实标注的弱化**:这里**不打日志**(每次连接都解析一次,打日志会刷屏);
|
|
22843
|
-
* 若宿主服务缺失,**退回"两个都 undefined"=旧行为**(即那条已知会失败的形态)——
|
|
22844
|
-
* 而不是静默编一个模型。⇒ 副作用是"没有默认模型时仍会失败",但那**与宿主自身缺配置时的行为一致**。
|
|
22845
|
-
*
|
|
22846
|
-
* 🔵 **公开导出**(而非模块私有):它是一个**纯函数 + 有独立契约**的单元(本仓同类先例 =
|
|
22847
|
-
* `host/access.ts` 的一组读取器),且**那个真缺陷正是在它这一层**(裸传 undefined)——
|
|
22848
|
-
* 回归钉必须能**直接钉住它**,而不是隔着 WS 握手去间接观察(那样测不出"取到了什么")。
|
|
22849
|
-
*/
|
|
22850
|
-
function resolveAcpSelection(ctx) {
|
|
22851
|
-
const envProvider = readAcpProvider();
|
|
22852
|
-
const envModel = readAcpModel();
|
|
22853
|
-
if (envProvider && envModel) return {
|
|
22854
|
-
provider: envProvider,
|
|
22855
|
-
model: envModel
|
|
22856
|
-
};
|
|
22857
|
-
try {
|
|
22858
|
-
const sel = hostInjected(ctx, "agentDefaultModel")?.currentSelection?.();
|
|
22859
|
-
if (sel?.provider && sel?.model) return {
|
|
22860
|
-
provider: sel.provider,
|
|
22861
|
-
model: sel.model
|
|
22862
|
-
};
|
|
22863
|
-
} catch {}
|
|
22864
|
-
return {
|
|
22865
|
-
provider: envProvider,
|
|
22866
|
-
model: envModel
|
|
22867
|
-
};
|
|
22868
|
-
}
|
|
22869
|
-
/**
|
|
22870
|
-
* 装配(`index.ts` 的 `apply` 调用):按**面板/部署键**启停面 E,配置变化热同步。
|
|
22871
|
-
*
|
|
22872
|
-
* 🔴 **W3 接线**(2026-10-16):本轮之前 `startAcpFace` 虽已实现却**没有任何调用点**
|
|
22873
|
-
* —— 即"面写好了但从不起"。本函数补上它,形式与两个兄弟面一致
|
|
22874
|
-
* (`registerWeixinSendApi` / `registerDiagramTool`):
|
|
22875
|
-
*
|
|
22876
|
-
* ① **反应式**:面板拨开关**只改配置、不重跑 `apply`**(v1.51.1 实测过的那条铁律)
|
|
22877
|
-
* ⇒ 必须监听事件才会"拨了就有反应";
|
|
22878
|
-
* ② 监听**宿主的真事件** `settings/document-updated`(按 ns 过滤),
|
|
22879
|
-
* ⛔ **不监听** `serenity/settings-changed` —— 那个事件**全仓没有任何 `emit` 点**
|
|
22880
|
-
* (=死通道;见 `registerDiagramTool` 的注释),照它接线等于接了个永远不响的铃;
|
|
22881
|
-
* ③ 再挂 `agent/created`/`session/created` 兜底(宿主事件面若换名,至少还有这条能自愈);
|
|
22882
|
-
* ④ 启动时同步一次(`apply` 时按当前配置定初始态)。
|
|
22883
|
-
*
|
|
22884
|
-
* ⚠️ **失败语义**:本面**默认关**且**依赖可选**(`dsh-acp` 是 peer)⇒ 起不来**只记日志**,
|
|
22885
|
-
* 绝不抛出(抛出会让整个插件加载失败,而它只是一个可选面)。
|
|
22886
|
-
*/
|
|
22887
|
-
function registerAcpFace(ctx) {
|
|
22888
|
-
const sync = () => {
|
|
22889
|
-
if (!acpFaceEnabled()) {
|
|
22890
|
-
if (acpFaceActive()) stopAcpFace();
|
|
22891
|
-
return;
|
|
22892
|
-
}
|
|
22893
|
-
if (acpFaceActive()) return;
|
|
22894
|
-
startAcpFace(ctx).catch((err) => {
|
|
22895
|
-
console.error(`[serenity-hooks] ✗ 面 E(官方 ACP)启动失败: ${String(err?.message ?? err)}`);
|
|
22896
|
-
});
|
|
22897
|
-
};
|
|
22898
|
-
try {
|
|
22899
|
-
ctx.on("settings/document-updated", (ns) => {
|
|
22900
|
-
if (String(ns) === "serenity-hooks") sync();
|
|
22901
|
-
});
|
|
22902
|
-
} catch {}
|
|
22903
|
-
for (const eventName of ["agent/created", "session/created"]) try {
|
|
22904
|
-
ctx.on(eventName, () => {
|
|
22905
|
-
sync();
|
|
22906
|
-
});
|
|
22907
|
-
} catch {}
|
|
22908
|
-
const refresh = () => {
|
|
22909
|
-
refreshAcpRoots(ctx).then((n) => {
|
|
22910
|
-
console.log(`[serenity-hooks] 面 E:CCC 候选根已枚举(${n} 个)`);
|
|
22911
|
-
});
|
|
22912
|
-
};
|
|
22913
|
-
try {
|
|
22914
|
-
ctx.on("settings/document-updated", (ns) => {
|
|
22915
|
-
if (String(ns) === "serenity-hooks") refresh();
|
|
22916
|
-
});
|
|
22917
|
-
} catch {}
|
|
22918
|
-
refresh();
|
|
22919
|
-
sync();
|
|
22920
|
-
}
|
|
22921
|
-
//#endregion
|
|
22922
21732
|
//#region src/seams/lifecycle.ts
|
|
22923
21733
|
/** 从 Agent / Session / 事件负载中取会话 id(形状宽容:未知结构返回 null) */
|
|
22924
21734
|
function sessionIdOf(value) {
|
|
@@ -25557,10 +24367,6 @@ const Config = z.object({
|
|
|
25557
24367
|
opencodeProvider: z.object({ autoConfigure: z.boolean().default(true) }),
|
|
25558
24368
|
visionPatch: z.object({ enabled: z.boolean().default(true) }),
|
|
25559
24369
|
diagram: z.object({ enabled: z.boolean().default(false) }),
|
|
25560
|
-
acpFace: z.object({
|
|
25561
|
-
enabled: z.boolean().default(false),
|
|
25562
|
-
port: z.number().min(1024).max(65535).default(ACP_PORT)
|
|
25563
|
-
}),
|
|
25564
24370
|
humanChannel: z.object({
|
|
25565
24371
|
accounts: z.array(humanChannelAccountSchema).default([]),
|
|
25566
24372
|
subscriptions: z.array(humanChannelSubscriptionSchema).default([]),
|
|
@@ -25572,9 +24378,7 @@ const Config = z.object({
|
|
|
25572
24378
|
skiffEnabled: z.boolean().volatile(),
|
|
25573
24379
|
skiffDebugPort: z.number().min(1024).max(65535).volatile(),
|
|
25574
24380
|
croEnabled: z.boolean().volatile(),
|
|
25575
|
-
diagramEnabled: z.boolean().volatile()
|
|
25576
|
-
acpFaceEnabled: z.boolean().volatile(),
|
|
25577
|
-
acpFacePort: z.number().min(1024).max(65535).volatile()
|
|
24381
|
+
diagramEnabled: z.boolean().volatile()
|
|
25578
24382
|
});
|
|
25579
24383
|
function apply(ctx, config) {
|
|
25580
24384
|
try {
|
|
@@ -25644,7 +24448,6 @@ function apply(ctx, config) {
|
|
|
25644
24448
|
registerWakeScheduler(ctx);
|
|
25645
24449
|
registerWeixinBridge(ctx);
|
|
25646
24450
|
registerWeixinSendApi(ctx);
|
|
25647
|
-
registerAcpFace(ctx);
|
|
25648
24451
|
registerWeixinOutputGuard(ctx);
|
|
25649
24452
|
registerLifecycle(ctx);
|
|
25650
24453
|
if (config.webFetch?.enabled !== false) registerWebFetchProvider(ctx);
|