@shgroup/dsh-serenity-hooks 1.59.0 → 1.59.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/dsh.plugin.json +1 -1
- package/lib/acp-auth.d.ts +156 -39
- package/lib/acp-face.d.ts +82 -3
- package/lib/ccc.d.ts +28 -5
- package/lib/client.js +305 -5
- package/lib/index.js +633 -69
- package/lib/localstore-ops-DMs_ovHo.js +282 -0
- package/lib/weixin-route-Aheyq3fN.js +281 -0
- package/package.json +1 -1
- package/lib/weixin-route-CSDBGLwl.js +0 -558
package/lib/index.js
CHANGED
|
@@ -2,10 +2,11 @@ import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.js";
|
|
|
2
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-GIJ3A_tw.js";
|
|
3
3
|
import { _ as resolveInside, a as findSerenityRoot$1, c as matchBlacklist, d as readCccName$1, f as readCheapWorkerConfig, h as readTrajectorySkills, i as findGitRoot, l as pathInside, m as readSafeModeHiddenTools, n as SAFE_MODE_MARKER, o as isSafeModeOn, p as readExclusiveTools, r as classifyPath, s as loadSerenityConfig, t as DEFAULT_SERENITY_CONFIG_PATHS, u as readBlacklist } from "./ccc-B0ZGL9lx.js";
|
|
4
4
|
import { A as localIdStamp, C as setActiveSessionInfo, D as isoLocal, E as useSession, O as localFileStamp, S as sessionsRoot, T as summarize, _ as namingTitleFor, a as pruneMissingBindings, b as resolveSessionByTitle, c as setBindingStore, d as clearActiveSessionInfo, f as createSession, g as listSessions, h as getActiveSessionInfo, i as listBoundSessionIds, k as localHuman, l as DEFAULT_SESSION_SCOPE, m as findSession, n as PLUGIN_SOURCE, o as readLastBound, p as extractSessionMdPathFromText, r as appendBound, s as resolveSessionTrajectoryLabel, t as ACC_MESSAGE_KIND, u as TRAJECTORY_ACTIONS, v as parseSessionContextFromEvents, w as showSession, x as sessionEvents$1, y as readActiveSessionMd } from "./message-source-B4P3Uw6D.js";
|
|
5
|
-
import {
|
|
5
|
+
import { a as readGitTrack, c as unsetEntry, i as localstorePath, l as writeEntry, n as checkLocalstoreGitCompliance, o as readStore, r as getEntry, s as runLocalStore, t as LOCALSTORE_SCOPES } from "./localstore-ops-DMs_ovHo.js";
|
|
6
6
|
import { a as FACE_PORTS, c as MECHANISM_PORTS, d as registerSkiffSession$1, f as skiffRoleFor$1, h as ACC_VERSION, i as ACP_PORT, l as SKIFF_DEBUG_PORT, m as unregisterSkiffSession$1, n as readSimpleSettings, o as GATEWAY_PORT, p as skiffSessionInfo$1, r as registerSettingsSection, s as MAIN_WEB_PORT, u as WEIXIN_SEND_PORT } from "./settings-section-DGUQZo5j.js";
|
|
7
7
|
import { a as readSkiffRoles, c as roleMsmWhitelist, d as systemPromptSource, f as trajectorySubset, i as isSkiffSessionId, l as roleToolWhitelist, n as buildSkiffBasePrompt, o as resolveRoleSystemPrompt, r as createRolePromptReader, s as resolveSkiffKind } from "./skiff-role-B615L9MV.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-Aheyq3fN.js";
|
|
9
10
|
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";
|
|
10
11
|
import { createRequire } from "node:module";
|
|
11
12
|
import z from "@deepseek-ai/schemastery";
|
|
@@ -15004,6 +15005,8 @@ const FILE_UPLOAD_PATH = "/serenity/file-upload";
|
|
|
15004
15005
|
const CONFIG_PATH = "/serenity/config";
|
|
15005
15006
|
const CCCS_PATH = "/serenity/cccs";
|
|
15006
15007
|
const WEIXIN_PATH = "/serenity/weixin";
|
|
15008
|
+
/** 🔴 v1.59.1:ACP 账户台账(面板数据面;形状照 `/serenity/weixin`) */
|
|
15009
|
+
const ACP_PATH = "/serenity/acp";
|
|
15007
15010
|
/** 图片落盘目录(CCC 根相对;S142 图片自动识别基础设施——粘贴图片落盘供 agent 经 CCC vlm MSM 自主处理) */
|
|
15008
15011
|
const IMAGE_UPLOAD_DIR = "_tmp/images_from_user";
|
|
15009
15012
|
const IMAGE_MEDIA_TYPES = /* @__PURE__ */ new Set([
|
|
@@ -15323,11 +15326,11 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15323
15326
|
sendJson$3(res, 400, { error: chk.error });
|
|
15324
15327
|
return;
|
|
15325
15328
|
}
|
|
15326
|
-
const { readWeixinSettings } = await import("./weixin-route-
|
|
15329
|
+
const { readWeixinSettings } = await import("./weixin-route-Aheyq3fN.js").then((n) => n.u);
|
|
15327
15330
|
const { weixinBridgeStatus } = await Promise.resolve().then(() => weixin_bridge_exports);
|
|
15328
15331
|
const settings = readWeixinSettings(chk.root);
|
|
15329
15332
|
const bridge = weixinBridgeStatus().find((b) => b.ccc === chk.root);
|
|
15330
|
-
const { readWeixinCredential } = await import("./weixin-route-
|
|
15333
|
+
const { readWeixinCredential } = await import("./weixin-route-Aheyq3fN.js").then((n) => n.u);
|
|
15331
15334
|
const accounts = (settings.accounts ?? []).map((a) => ({
|
|
15332
15335
|
accountId: a.accountId,
|
|
15333
15336
|
name: a.name ?? void 0,
|
|
@@ -15359,7 +15362,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15359
15362
|
const { fetchQRCode } = await import("./weixin-api-BrRcPiQs.js").then((n) => n.u);
|
|
15360
15363
|
const { randomUUID } = await import("node:crypto");
|
|
15361
15364
|
for (const [key, v] of weixinLogins) if (Date.now() - v.startedAt > WEIXIN_LOGIN_TTL_MS) weixinLogins.delete(key);
|
|
15362
|
-
const { readWeixinSettings } = await import("./weixin-route-
|
|
15365
|
+
const { readWeixinSettings } = await import("./weixin-route-Aheyq3fN.js").then((n) => n.u);
|
|
15363
15366
|
const qr = await fetchQRCode({ botType: readWeixinSettings(root).botType ?? void 0 });
|
|
15364
15367
|
const loginKey = randomUUID();
|
|
15365
15368
|
weixinLogins.set(loginKey, {
|
|
@@ -15380,7 +15383,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15380
15383
|
sendJson$3(res, 400, { error: "missing accountId" });
|
|
15381
15384
|
return;
|
|
15382
15385
|
}
|
|
15383
|
-
const { removeWeixinAccount } = await import("./weixin-route-
|
|
15386
|
+
const { removeWeixinAccount } = await import("./weixin-route-Aheyq3fN.js").then((n) => n.u);
|
|
15384
15387
|
const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
|
|
15385
15388
|
removeWeixinAccount(root, body.accountId);
|
|
15386
15389
|
syncCccBridge(ctx, root);
|
|
@@ -15393,7 +15396,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15393
15396
|
sendJson$3(res, 400, { error: "invalid routes (expected [{user, role}, ...])" });
|
|
15394
15397
|
return;
|
|
15395
15398
|
}
|
|
15396
|
-
const { saveWeixinRoutes } = await import("./weixin-route-
|
|
15399
|
+
const { saveWeixinRoutes } = await import("./weixin-route-Aheyq3fN.js").then((n) => n.u);
|
|
15397
15400
|
const { readSkiffRoles } = await import("./skiff-role-B615L9MV.js").then((n) => n.u);
|
|
15398
15401
|
const roles = readSkiffRoles(root);
|
|
15399
15402
|
for (const r of routes) if (!roles.has(r.role)) {
|
|
@@ -15406,7 +15409,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15406
15409
|
}
|
|
15407
15410
|
if (body.action === "set-enabled") {
|
|
15408
15411
|
const enabled = body.enabled === true;
|
|
15409
|
-
const { readWeixinSettings, setWeixinEnabled } = await import("./weixin-route-
|
|
15412
|
+
const { readWeixinSettings, setWeixinEnabled } = await import("./weixin-route-Aheyq3fN.js").then((n) => n.u);
|
|
15410
15413
|
const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
|
|
15411
15414
|
const settings = readWeixinSettings(root);
|
|
15412
15415
|
if (enabled && (settings.accounts ?? []).length === 0) {
|
|
@@ -15450,7 +15453,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15450
15453
|
return;
|
|
15451
15454
|
}
|
|
15452
15455
|
const { pollQRStatus } = await import("./weixin-api-BrRcPiQs.js").then((n) => n.u);
|
|
15453
|
-
const { readWeixinSettings } = await import("./weixin-route-
|
|
15456
|
+
const { readWeixinSettings } = await import("./weixin-route-Aheyq3fN.js").then((n) => n.u);
|
|
15454
15457
|
const settings = readWeixinSettings(login.root);
|
|
15455
15458
|
const status = await pollQRStatus({
|
|
15456
15459
|
baseUrl: void 0,
|
|
@@ -15465,7 +15468,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15465
15468
|
});
|
|
15466
15469
|
return;
|
|
15467
15470
|
}
|
|
15468
|
-
const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./weixin-route-
|
|
15471
|
+
const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./weixin-route-Aheyq3fN.js").then((n) => n.u);
|
|
15469
15472
|
const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
|
|
15470
15473
|
const accountId = nextWeixinAccountId(settings);
|
|
15471
15474
|
upsertWeixinAccount(login.root, {
|
|
@@ -15549,6 +15552,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15549
15552
|
}
|
|
15550
15553
|
}
|
|
15551
15554
|
});
|
|
15555
|
+
registerAcpAccountsApi(ctx);
|
|
15552
15556
|
}
|
|
15553
15557
|
/** 进行中的扫码登录(loginKey → 状态;进程内,5min 有效) */
|
|
15554
15558
|
const weixinLogins = /* @__PURE__ */ new Map();
|
|
@@ -15569,6 +15573,99 @@ function requireCcc(root) {
|
|
|
15569
15573
|
root: serenityRoot
|
|
15570
15574
|
};
|
|
15571
15575
|
}
|
|
15576
|
+
/** ACP 账户台账端点(面板专用;形状照 `/serenity/weixin`) */
|
|
15577
|
+
function registerAcpAccountsApi(ctx) {
|
|
15578
|
+
ctx.webServer.register({
|
|
15579
|
+
kind: "exact",
|
|
15580
|
+
path: ACP_PATH,
|
|
15581
|
+
handler: async (req, res) => {
|
|
15582
|
+
try {
|
|
15583
|
+
if (!requireWebUi(req, res, "ACP 账户配置仅限 WebUI(client 专用)")) return;
|
|
15584
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
15585
|
+
if (req.method === "GET") {
|
|
15586
|
+
const chk = requireCcc(url.searchParams.get("ccc") ?? void 0);
|
|
15587
|
+
if (!chk.ok) {
|
|
15588
|
+
sendJson$3(res, 400, { error: chk.error });
|
|
15589
|
+
return;
|
|
15590
|
+
}
|
|
15591
|
+
const { acpChannelStatus, listAcpRoots } = await Promise.resolve().then(() => acp_auth_exports);
|
|
15592
|
+
const { listCccs } = await import("./ccc-roots-GIJ3A_tw.js").then((n) => n.a);
|
|
15593
|
+
const st = acpChannelStatus(chk.root);
|
|
15594
|
+
const all = (await listCccs(ctx)).map((e) => e.root);
|
|
15595
|
+
sendJson$3(res, 200, {
|
|
15596
|
+
channel: st.channel,
|
|
15597
|
+
enabled: st.enabled,
|
|
15598
|
+
accounts: st.accounts,
|
|
15599
|
+
roots: listAcpRoots(all)
|
|
15600
|
+
});
|
|
15601
|
+
return;
|
|
15602
|
+
}
|
|
15603
|
+
if (req.method !== "POST") {
|
|
15604
|
+
sendJson$3(res, 405, { error: "method not allowed" });
|
|
15605
|
+
return;
|
|
15606
|
+
}
|
|
15607
|
+
const raw = await readBody$3(req, 65536);
|
|
15608
|
+
const body = JSON.parse(raw);
|
|
15609
|
+
const chk = requireCcc(body.ccc);
|
|
15610
|
+
if (!chk.ok) {
|
|
15611
|
+
sendJson$3(res, 400, { error: chk.error });
|
|
15612
|
+
return;
|
|
15613
|
+
}
|
|
15614
|
+
const root = chk.root;
|
|
15615
|
+
const acp = await Promise.resolve().then(() => acp_auth_exports);
|
|
15616
|
+
if (body.action === "upsert") {
|
|
15617
|
+
const account = acp.normalizeAcpAccount(body.account);
|
|
15618
|
+
if (!account) {
|
|
15619
|
+
sendJson$3(res, 400, { error: "invalid account (accountId 与 role 均必填)" });
|
|
15620
|
+
return;
|
|
15621
|
+
}
|
|
15622
|
+
const { readSkiffRoles } = await import("./skiff-role-B615L9MV.js").then((n) => n.u);
|
|
15623
|
+
const roles = readSkiffRoles(root);
|
|
15624
|
+
if (!roles.has(account.role)) {
|
|
15625
|
+
sendJson$3(res, 400, { error: `role "${account.role}" 不在该 CCC 的 skiff.roles 里(现有:${[...roles.keys()].join(", ") || "无"})` });
|
|
15626
|
+
return;
|
|
15627
|
+
}
|
|
15628
|
+
acp.upsertAcpAccount(root, account);
|
|
15629
|
+
const created = acp.ensureAcpChannelTokens(root).includes(account.accountId);
|
|
15630
|
+
sendJson$3(res, 200, {
|
|
15631
|
+
saved: true,
|
|
15632
|
+
accounts: acp.acpChannelStatus(root).accounts,
|
|
15633
|
+
token: created ? acp.readAcpToken(root, account.accountId) : void 0
|
|
15634
|
+
});
|
|
15635
|
+
return;
|
|
15636
|
+
}
|
|
15637
|
+
if (body.action === "remove") {
|
|
15638
|
+
const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
|
|
15639
|
+
if (accountId === "") {
|
|
15640
|
+
sendJson$3(res, 400, { error: "missing accountId" });
|
|
15641
|
+
return;
|
|
15642
|
+
}
|
|
15643
|
+
acp.removeAcpAccount(root, accountId);
|
|
15644
|
+
sendJson$3(res, 200, {
|
|
15645
|
+
removed: accountId,
|
|
15646
|
+
accounts: acp.acpChannelStatus(root).accounts
|
|
15647
|
+
});
|
|
15648
|
+
return;
|
|
15649
|
+
}
|
|
15650
|
+
if (body.action === "rotate") {
|
|
15651
|
+
const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
|
|
15652
|
+
if (accountId === "") {
|
|
15653
|
+
sendJson$3(res, 400, { error: "missing accountId" });
|
|
15654
|
+
return;
|
|
15655
|
+
}
|
|
15656
|
+
sendJson$3(res, 200, {
|
|
15657
|
+
rotated: accountId,
|
|
15658
|
+
token: acp.resetAcpToken(root, accountId)
|
|
15659
|
+
});
|
|
15660
|
+
return;
|
|
15661
|
+
}
|
|
15662
|
+
sendJson$3(res, 400, { error: `unknown action: ${String(body.action)}` });
|
|
15663
|
+
} catch (err) {
|
|
15664
|
+
sendJson$3(res, 400, { error: err?.message ?? String(err) });
|
|
15665
|
+
}
|
|
15666
|
+
}
|
|
15667
|
+
});
|
|
15668
|
+
}
|
|
15572
15669
|
//#endregion
|
|
15573
15670
|
//#region src/voyage-page.ts
|
|
15574
15671
|
const VOYAGE_PATH = "/serenity/voyage";
|
|
@@ -21853,19 +21950,59 @@ function registerWeixinSendApi(ctx) {
|
|
|
21853
21950
|
*
|
|
21854
21951
|
* 本模块**零 DSH 依赖**(只 `node:crypto` / `node:fs` / `node:path` 与本仓纯逻辑)⇒ 可独立单测。
|
|
21855
21952
|
*/
|
|
21856
|
-
|
|
21857
|
-
|
|
21953
|
+
var acp_auth_exports = /* @__PURE__ */ __exportAll({
|
|
21954
|
+
acpChannelEnabled: () => acpChannelEnabled,
|
|
21955
|
+
acpChannelStatus: () => acpChannelStatus,
|
|
21956
|
+
acpSessionKeyFor: () => acpSessionKeyFor,
|
|
21957
|
+
acpTokenKeyFor: () => acpTokenKeyFor,
|
|
21958
|
+
cwdInsideRoot: () => cwdInsideRoot,
|
|
21959
|
+
ensureAcpChannelTokens: () => ensureAcpChannelTokens,
|
|
21960
|
+
ensureAcpToken: () => ensureAcpToken,
|
|
21961
|
+
expandAcpAccounts: () => expandAcpAccounts,
|
|
21962
|
+
extractToken: () => extractToken,
|
|
21963
|
+
listAcpRoots: () => listAcpRoots,
|
|
21964
|
+
normalizeAcpAccount: () => normalizeAcpAccount,
|
|
21965
|
+
readAcpMainSession: () => readAcpMainSession,
|
|
21966
|
+
readAcpToken: () => readAcpToken,
|
|
21967
|
+
readImSettings: () => readImSettings,
|
|
21968
|
+
removeAcpAccount: () => removeAcpAccount,
|
|
21969
|
+
resetAcpToken: () => resetAcpToken,
|
|
21970
|
+
resolveTokenAccount: () => resolveTokenAccount,
|
|
21971
|
+
safeEqual: () => safeEqual,
|
|
21972
|
+
saveAcpAccounts: () => saveAcpAccounts,
|
|
21973
|
+
tokenFingerprint: () => tokenFingerprint,
|
|
21974
|
+
upsertAcpAccount: () => upsertAcpAccount,
|
|
21975
|
+
writeAcpMainSession: () => writeAcpMainSession
|
|
21976
|
+
});
|
|
21977
|
+
/** 多账户凭据键前缀(`IM_ACP_TOKEN_<ACCOUNT_ID>`;与微信桥 `WEIXIN_<ID>_<FIELD>` 同构) */
|
|
21978
|
+
const ACP_TOKEN_PREFIX = "IM_ACP_TOKEN_";
|
|
21979
|
+
/**
|
|
21980
|
+
* `accountId` → UPPER_SNAKE 片段(`agmente-phone` → `AGMENTE_PHONE`)。
|
|
21981
|
+
*
|
|
21982
|
+
* 🔴 **与 `weixin-route.ts` 的 `accountKeyPart` 逐字同规则** —— 刻意复制而非共享:
|
|
21983
|
+
* 两者属不同子域(微信 vs ACP),共享会让"改微信键规则"意外改掉 ACP 的既有凭据键,
|
|
21984
|
+
* 而凭据键**改一次就等于全体客户端掉线**。⇒ 用"同规则的两份"换"互不牵连"。
|
|
21985
|
+
*/
|
|
21986
|
+
function accountKeyPart(accountId) {
|
|
21987
|
+
return accountId.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
|
|
21988
|
+
}
|
|
21989
|
+
/** 该账户的凭据键(`IM_ACP_TOKEN_<ACCOUNT_ID>`);`accountId` 必填 */
|
|
21990
|
+
function acpTokenKeyFor(accountId) {
|
|
21991
|
+
return `${ACP_TOKEN_PREFIX}${accountKeyPart(accountId)}`;
|
|
21992
|
+
}
|
|
21858
21993
|
/** token 字节数(32 B = 256 bit;hex 后 64 字符) */
|
|
21859
21994
|
const TOKEN_BYTES = 32;
|
|
21860
21995
|
/**
|
|
21861
|
-
*
|
|
21996
|
+
* 该**账户**的 ACP token(**读**;不存在返回 null)。
|
|
21997
|
+
*
|
|
21998
|
+
* 🔴 **不自动生成** —— 生成是**写操作**,只在显式要求时做(`ensureAcpToken`)。
|
|
21999
|
+
* 读路径永远无副作用:否则每次判定都会改 localstore。
|
|
21862
22000
|
*
|
|
21863
|
-
*
|
|
21864
|
-
* (`ensureAcpToken`)。读路径永远无副作用:否则每次启动都会改 localstore。
|
|
22001
|
+
* @param accountId 账户键(必填 —— 凭据按账户分离,没有"CCC 默认 token"这回事)
|
|
21865
22002
|
*/
|
|
21866
|
-
function readAcpToken(root) {
|
|
22003
|
+
function readAcpToken(root, accountId) {
|
|
21867
22004
|
try {
|
|
21868
|
-
const value = getEntry(root, "credential",
|
|
22005
|
+
const value = getEntry(root, "credential", acpTokenKeyFor(accountId));
|
|
21869
22006
|
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
21870
22007
|
} catch {
|
|
21871
22008
|
return null;
|
|
@@ -21876,12 +22013,23 @@ function readAcpToken(root) {
|
|
|
21876
22013
|
*
|
|
21877
22014
|
* ⚠️ **幂等**:已有 token 时**原样返回**(不轮换)—— 轮换会让在用的客户端立刻断线,
|
|
21878
22015
|
* 必须是一次显式动作(`resetAcpToken`)。
|
|
22016
|
+
*
|
|
22017
|
+
* @param accountId 多账户时指明哪个账户;省略 = 单账户简写键
|
|
21879
22018
|
*/
|
|
21880
|
-
function ensureAcpToken(root) {
|
|
21881
|
-
const existing = readAcpToken(root);
|
|
22019
|
+
function ensureAcpToken(root, accountId) {
|
|
22020
|
+
const existing = readAcpToken(root, accountId);
|
|
21882
22021
|
if (existing) return existing;
|
|
21883
22022
|
const token = randomBytes(TOKEN_BYTES).toString("hex");
|
|
21884
|
-
writeEntry(root, "credential",
|
|
22023
|
+
writeEntry(root, "credential", acpTokenKeyFor(accountId), token);
|
|
22024
|
+
return token;
|
|
22025
|
+
}
|
|
22026
|
+
/**
|
|
22027
|
+
* 轮换 token(显式动作;返回新值)。旧 token 立即失效。
|
|
22028
|
+
* @param accountId 账户键
|
|
22029
|
+
*/
|
|
22030
|
+
function resetAcpToken(root, accountId) {
|
|
22031
|
+
const token = randomBytes(TOKEN_BYTES).toString("hex");
|
|
22032
|
+
writeEntry(root, "credential", acpTokenKeyFor(accountId), token);
|
|
21885
22033
|
return token;
|
|
21886
22034
|
}
|
|
21887
22035
|
/**
|
|
@@ -21900,6 +22048,49 @@ function safeEqual(a, b) {
|
|
|
21900
22048
|
function tokenFingerprint(token) {
|
|
21901
22049
|
return createHash("sha256").update(token).digest("hex").slice(0, 12);
|
|
21902
22050
|
}
|
|
22051
|
+
/** 主会话键前缀(`IM_ACP_SESSION_<ACCOUNT_ID>`;与 `IM_ACP_TOKEN_<ACCOUNT_ID>` 同族同规则) */
|
|
22052
|
+
const ACP_SESSION_PREFIX = "IM_ACP_SESSION_";
|
|
22053
|
+
/**
|
|
22054
|
+
* 该账户的**主会话**键(`IM_ACP_SESSION_<ACCOUNT_ID>`)。
|
|
22055
|
+
*
|
|
22056
|
+
* 🔴 为什么放在 **credential** 节而不是 config 节:它跟 token 一样是**每账户一值的键控数据**
|
|
22057
|
+
* (键由 `accountId` 派生、随账户生命周期存亡)。放 config 节要另造一层 `section.key`
|
|
22058
|
+
* 结构,而这一层的唯一读者就是本模块 ⇒ 多一层结构 = 多一处会漂的形状。
|
|
22059
|
+
*
|
|
22060
|
+
* ⚠️ 它**不是凭据**(会话 id 不是秘密)—— 共享 credential 节只是为了复用它既有的
|
|
22061
|
+
* 「按 accountId 派生键 + 删账户时一并清理」这套语义,不引入第二套键规则。
|
|
22062
|
+
*/
|
|
22063
|
+
function acpSessionKeyFor(accountId) {
|
|
22064
|
+
return `${ACP_SESSION_PREFIX}${accountKeyPart(accountId)}`;
|
|
22065
|
+
}
|
|
22066
|
+
/**
|
|
22067
|
+
* 读该账户的主会话 id(不存在 ⇒ `null`)。
|
|
22068
|
+
*
|
|
22069
|
+
* 🔴 **读路径无副作用**(与 `readAcpToken` 同纪律):不存在的含义是「这个账户还没有主会话」,
|
|
22070
|
+
* 而那时的正确行为是**让 App 去建**(`session/list` 回空数组),不是替它编一个。
|
|
22071
|
+
*/
|
|
22072
|
+
function readAcpMainSession(root, accountId) {
|
|
22073
|
+
try {
|
|
22074
|
+
const value = getEntry(root, "credential", acpSessionKeyFor(accountId));
|
|
22075
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
22076
|
+
} catch {
|
|
22077
|
+
return null;
|
|
22078
|
+
}
|
|
22079
|
+
}
|
|
22080
|
+
/**
|
|
22081
|
+
* 记下该账户的主会话 id(**写操作**;由 `session/new` 的出站响应触发)。
|
|
22082
|
+
*
|
|
22083
|
+
* 🔴 **后写覆盖先写**(last-wins):owner 要的是"一条长期对话",而"再建一条"是用户
|
|
22084
|
+
* 在 App 里显式点「新对话」的结果 ⇒ 那时他要的就是**新的那条**。
|
|
22085
|
+
* ⛔ **被否的两种做法**:① 已有主会话就拒建(用户会看到"点了没反应");
|
|
22086
|
+
* ② 把 `session/new` 改写成 `session/resume`(响应形状不同,要让客户端认一条
|
|
22087
|
+
* **不存在于协议里**的响应 —— 那是拿协议兼容性换一个我们本来就有的语义)。
|
|
22088
|
+
*/
|
|
22089
|
+
function writeAcpMainSession(root, accountId, sessionId) {
|
|
22090
|
+
const id = String(sessionId ?? "").trim();
|
|
22091
|
+
if (id === "") return;
|
|
22092
|
+
writeEntry(root, "credential", acpSessionKeyFor(accountId), id);
|
|
22093
|
+
}
|
|
21903
22094
|
/**
|
|
21904
22095
|
* 从一次 HTTP upgrade 请求里取出候选 token。
|
|
21905
22096
|
*
|
|
@@ -21922,32 +22113,92 @@ function extractToken(headers, url) {
|
|
|
21922
22113
|
return null;
|
|
21923
22114
|
}
|
|
21924
22115
|
/**
|
|
21925
|
-
*
|
|
22116
|
+
* 把该 CCC 的配置**展开成账户列表**(读时现算,**无快照**)。
|
|
22117
|
+
*
|
|
22118
|
+
* 🔴 **`im.acp.accounts` 是必填项** —— 没有它就没有账户,**一个都认证不了**。
|
|
22119
|
+
* 刻意**不提供**"单账户简写"退回路径(v1.59.1):那会多出一条永不生效的分支,
|
|
22120
|
+
* 而它的唯一作用是把"配置写错了"伪装成"用了简写"。
|
|
22121
|
+
*
|
|
22122
|
+
* 判据(三条都由调用方以 fail-closed 消费):
|
|
22123
|
+
* - `enabled !== false`(缺省 true)
|
|
22124
|
+
* - `role` 非空(空 ⇒ 该账户**不出现**在列表里 ⇒ 必然登录失败)
|
|
22125
|
+
* - token 存在(不存在 ⇒ 同样不出现)
|
|
22126
|
+
*
|
|
22127
|
+
* ⚠️ 本函数**纯读,绝不生成 token** —— 生成是写操作,归 `ensureAcpChannelTokens`。
|
|
22128
|
+
* 这样 upgrade 路径可以随时现算而不冒"每次连接都写 localstore"的风险。
|
|
22129
|
+
*/
|
|
22130
|
+
function expandAcpAccounts(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22131
|
+
const im = readImSettings(root, paths);
|
|
22132
|
+
if (im.channel !== "acp" || im.acp.enabled !== true) return [];
|
|
22133
|
+
const declared = Array.isArray(im.acp.accounts) ? im.acp.accounts : [];
|
|
22134
|
+
const out = [];
|
|
22135
|
+
for (const a of declared) {
|
|
22136
|
+
if (typeof a !== "object" || a === null) continue;
|
|
22137
|
+
if (a.enabled === false) continue;
|
|
22138
|
+
const accountId = typeof a.accountId === "string" ? a.accountId.trim() : "";
|
|
22139
|
+
if (accountId === "") continue;
|
|
22140
|
+
const role = typeof a.role === "string" ? a.role.trim() : "";
|
|
22141
|
+
if (role === "") continue;
|
|
22142
|
+
const token = readAcpToken(root, accountId);
|
|
22143
|
+
if (!token) continue;
|
|
22144
|
+
out.push({
|
|
22145
|
+
root,
|
|
22146
|
+
accountId,
|
|
22147
|
+
tokenKey: acpTokenKeyFor(accountId),
|
|
22148
|
+
token,
|
|
22149
|
+
role
|
|
22150
|
+
});
|
|
22151
|
+
}
|
|
22152
|
+
return out;
|
|
22153
|
+
}
|
|
22154
|
+
/**
|
|
22155
|
+
* **让配置里声明的账户都拥有 token**(写操作;幂等;由装配层与配置变更时调用)。
|
|
22156
|
+
*
|
|
22157
|
+
* 🔴 owner 令「**随即生成**认证用的 token」的落地点。与 v1.59.0 的差别:
|
|
22158
|
+
* 那时只在**启动**时跑一次 ⇒ 加一台设备要重启;现在**任何一次判定前都可以跑**,
|
|
22159
|
+
* 且撤账户后不会被"复活"(只对**当前声明里**的账户生成)。
|
|
22160
|
+
*
|
|
22161
|
+
* @returns 本次**新生成**的账户键列表(已存在的原样返回,不轮换)
|
|
22162
|
+
*/
|
|
22163
|
+
function ensureAcpChannelTokens(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22164
|
+
const im = readImSettings(root, paths);
|
|
22165
|
+
if (im.channel !== "acp" || im.acp.enabled !== true) return [];
|
|
22166
|
+
const declared = Array.isArray(im.acp.accounts) ? im.acp.accounts : [];
|
|
22167
|
+
const created = [];
|
|
22168
|
+
for (const a of declared) {
|
|
22169
|
+
if (typeof a !== "object" || a === null) continue;
|
|
22170
|
+
if (a.enabled === false) continue;
|
|
22171
|
+
const accountId = typeof a.accountId === "string" ? a.accountId.trim() : "";
|
|
22172
|
+
if (accountId === "") continue;
|
|
22173
|
+
try {
|
|
22174
|
+
if (!readAcpToken(root, accountId)) {
|
|
22175
|
+
ensureAcpToken(root, accountId);
|
|
22176
|
+
created.push(accountId);
|
|
22177
|
+
}
|
|
22178
|
+
} catch {}
|
|
22179
|
+
}
|
|
22180
|
+
return created;
|
|
22181
|
+
}
|
|
22182
|
+
/**
|
|
22183
|
+
* 校验一枚候选 token,返回它归属的**账户**(未命中返回 null)。
|
|
21926
22184
|
*
|
|
21927
22185
|
* @param candidate 客户端出示的 token
|
|
21928
22186
|
* @param roots 候选 CCC 根集合(由调用方枚举——本模块不认识"CCC 在哪")
|
|
21929
22187
|
*/
|
|
21930
|
-
function
|
|
22188
|
+
function resolveTokenAccount(candidate, roots, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
21931
22189
|
if (!candidate) return null;
|
|
21932
|
-
for (const root of roots)
|
|
21933
|
-
const token = readAcpToken(root);
|
|
21934
|
-
if (token && safeEqual(candidate, token)) return root;
|
|
21935
|
-
}
|
|
22190
|
+
for (const root of roots) for (const acct of expandAcpAccounts(root, paths)) if (safeEqual(candidate, acct.token)) return acct;
|
|
21936
22191
|
return null;
|
|
21937
22192
|
}
|
|
21938
22193
|
/**
|
|
21939
|
-
*
|
|
22194
|
+
* 枚举**有可用 ACP 账户**的 CCC(供准入名单用)。
|
|
21940
22195
|
*
|
|
21941
|
-
*
|
|
21942
|
-
*
|
|
22196
|
+
* 判据:`im.channel === 'acp'` ∧ `im.acp.enabled === true` ∧ **至少一个账户有 token 且配了角色**。
|
|
22197
|
+
* ⚠️ 缺一即不算(fail-closed:宁可连不上,不可放行一个半配置的通道)。
|
|
21943
22198
|
*/
|
|
21944
22199
|
function listAcpRoots(roots, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
21945
22200
|
const out = [];
|
|
21946
|
-
for (const root of roots)
|
|
21947
|
-
if (!acpChannelEnabled(root, paths)) continue;
|
|
21948
|
-
if (!readAcpToken(root)) continue;
|
|
21949
|
-
out.push(root);
|
|
21950
|
-
}
|
|
22201
|
+
for (const root of roots) if (expandAcpAccounts(root, paths).length > 0) out.push(root);
|
|
21951
22202
|
return out;
|
|
21952
22203
|
}
|
|
21953
22204
|
/** 读该 CCC 的 `im` 段(未配置 ⇒ 归一缺省;永不 undefined) */
|
|
@@ -21980,11 +22231,6 @@ function acpChannelEnabled(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
|
21980
22231
|
const im = readImSettings(root, paths);
|
|
21981
22232
|
return im.channel === "acp" && im.acp.enabled === true;
|
|
21982
22233
|
}
|
|
21983
|
-
/** 该 CCC 绑定的 skiff 角色名(未配置 ⇒ null) */
|
|
21984
|
-
function acpRoleFor(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
21985
|
-
const role = readImSettings(root, paths).acp.role;
|
|
21986
|
-
return typeof role === "string" && role.trim() !== "" ? role.trim() : null;
|
|
21987
|
-
}
|
|
21988
22234
|
/**
|
|
21989
22235
|
* 客户端 `session/new` 传来的 `cwd` 是否落在该 token 归属的 CCC 内。
|
|
21990
22236
|
*
|
|
@@ -21999,6 +22245,126 @@ function cwdInsideRoot(cwd, root) {
|
|
|
21999
22245
|
const rel = relative(resolve(root), resolve(cwd));
|
|
22000
22246
|
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
22001
22247
|
}
|
|
22248
|
+
/** 该 CCC 的 ACP 通道对外**可见形态**(诊断面用;**绝不含 token 明文**) */
|
|
22249
|
+
function acpChannelStatus(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22250
|
+
const im = readImSettings(root, paths);
|
|
22251
|
+
const declared = Array.isArray(im.acp.accounts) ? im.acp.accounts : [];
|
|
22252
|
+
const usable = new Set(expandAcpAccounts(root, paths).map((a) => a.accountId));
|
|
22253
|
+
const accounts = [];
|
|
22254
|
+
for (const a of declared) {
|
|
22255
|
+
if (typeof a !== "object" || a === null) continue;
|
|
22256
|
+
const accountId = typeof a.accountId === "string" ? a.accountId.trim() : "";
|
|
22257
|
+
if (accountId === "") continue;
|
|
22258
|
+
const role = typeof a.role === "string" && a.role.trim() !== "" ? a.role.trim() : null;
|
|
22259
|
+
const token = readAcpToken(root, accountId);
|
|
22260
|
+
const enabled = a.enabled !== false;
|
|
22261
|
+
let blockedBy = null;
|
|
22262
|
+
if (!enabled) blockedBy = "该账户 enabled=false";
|
|
22263
|
+
else if (!role) blockedBy = "未配 role";
|
|
22264
|
+
else if (!token) blockedBy = "尚无 token(保存配置后自动生成)";
|
|
22265
|
+
accounts.push({
|
|
22266
|
+
accountId,
|
|
22267
|
+
name: typeof a.name === "string" && a.name.trim() !== "" ? a.name.trim() : null,
|
|
22268
|
+
role,
|
|
22269
|
+
enabled,
|
|
22270
|
+
hasToken: token !== null,
|
|
22271
|
+
tokenFingerprint: token ? tokenFingerprint(token) : null,
|
|
22272
|
+
mainSession: readAcpMainSession(root, accountId),
|
|
22273
|
+
usable: usable.has(accountId),
|
|
22274
|
+
blockedBy
|
|
22275
|
+
});
|
|
22276
|
+
}
|
|
22277
|
+
return {
|
|
22278
|
+
channel: im.channel,
|
|
22279
|
+
enabled: acpChannelEnabled(root, paths),
|
|
22280
|
+
accounts
|
|
22281
|
+
};
|
|
22282
|
+
}
|
|
22283
|
+
/**
|
|
22284
|
+
* 读取 serenity.json 原文(不存在 → 空对象)。
|
|
22285
|
+
*
|
|
22286
|
+
* ⚠️ 与 `weixin-route.ts` 的同名函数**刻意各写一份**:两者写的是**不同段**
|
|
22287
|
+
* (`weixin` vs `im.acp.accounts`),共用一个会在"某天只想改其中一段"时
|
|
22288
|
+
* 意外把另一段也重写一遍。同规则两份 > 一个会互相牵连的共用件。
|
|
22289
|
+
*/
|
|
22290
|
+
function readRawConfig(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22291
|
+
for (const candidate of paths) {
|
|
22292
|
+
const p = resolve(root, candidate);
|
|
22293
|
+
if (existsSync(p)) try {
|
|
22294
|
+
const v = JSON.parse(readFileSync(p, "utf-8").replace(/^\uFEFF/, ""));
|
|
22295
|
+
if (v && typeof v === "object" && !Array.isArray(v)) return v;
|
|
22296
|
+
} catch {
|
|
22297
|
+
return {};
|
|
22298
|
+
}
|
|
22299
|
+
}
|
|
22300
|
+
return {};
|
|
22301
|
+
}
|
|
22302
|
+
/**
|
|
22303
|
+
* 写回 `im.acp.accounts`(保留文件里其余所有字段)。
|
|
22304
|
+
*
|
|
22305
|
+
* 🔴 **只碰这一个键**:`im.channel` / `im.acp.enabled` 归面板总开关与人工配置,
|
|
22306
|
+
* 本函数不越权改它们(否则"加一台设备"会顺手把通道开关也改了)。
|
|
22307
|
+
*/
|
|
22308
|
+
function saveAcpAccounts(root, accounts, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22309
|
+
const target = resolve(root, paths[0] ?? ".opencode/serenity.json");
|
|
22310
|
+
const raw = readRawConfig(root, paths);
|
|
22311
|
+
const im = raw.im && typeof raw.im === "object" ? raw.im : {};
|
|
22312
|
+
const acp = im.acp && typeof im.acp === "object" ? im.acp : {};
|
|
22313
|
+
acp.accounts = accounts;
|
|
22314
|
+
im.acp = acp;
|
|
22315
|
+
raw.im = im;
|
|
22316
|
+
const dir = resolve(target, "..");
|
|
22317
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
22318
|
+
writeFileSync(target, JSON.stringify(raw, null, 2) + "\n", "utf-8");
|
|
22319
|
+
return accounts;
|
|
22320
|
+
}
|
|
22321
|
+
/**
|
|
22322
|
+
* 规范化一条账户声明(面板传入的任意形状 → 合法声明;非法 ⇒ null)。
|
|
22323
|
+
*
|
|
22324
|
+
* 🔴 返回类型里 `role` 是**必填**(不是 `ImAcpAccount` 的可选形态)——
|
|
22325
|
+
* 本函数的契约就是"要么给一个角色齐备的账户,要么给 null"。
|
|
22326
|
+
* 这样调用方**不必再判一次** `role` 有没有(那正是会漂的第二处判据)。
|
|
22327
|
+
*/
|
|
22328
|
+
function normalizeAcpAccount(input) {
|
|
22329
|
+
if (typeof input !== "object" || input === null) return null;
|
|
22330
|
+
const a = input;
|
|
22331
|
+
const accountId = typeof a.accountId === "string" ? a.accountId.trim() : "";
|
|
22332
|
+
if (accountId === "") return null;
|
|
22333
|
+
const role = typeof a.role === "string" ? a.role.trim() : "";
|
|
22334
|
+
if (role === "") return null;
|
|
22335
|
+
const out = {
|
|
22336
|
+
accountId,
|
|
22337
|
+
role
|
|
22338
|
+
};
|
|
22339
|
+
if (typeof a.name === "string" && a.name.trim() !== "") out.name = a.name.trim();
|
|
22340
|
+
if (a.enabled === false) out.enabled = false;
|
|
22341
|
+
return out;
|
|
22342
|
+
}
|
|
22343
|
+
/** 增/改一条账户(按 accountId 定位;写入后返回全表) */
|
|
22344
|
+
function upsertAcpAccount(root, account, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22345
|
+
const list = [...readImSettings(root, paths).acp.accounts ?? []];
|
|
22346
|
+
const idx = list.findIndex((a) => a.accountId === account.accountId);
|
|
22347
|
+
if (idx >= 0) list[idx] = {
|
|
22348
|
+
...list[idx],
|
|
22349
|
+
...account
|
|
22350
|
+
};
|
|
22351
|
+
else list.push(account);
|
|
22352
|
+
return saveAcpAccounts(root, list, paths);
|
|
22353
|
+
}
|
|
22354
|
+
/**
|
|
22355
|
+
* 删一条账户(**同时清掉它的 token + 主会话**,避免留死键)。
|
|
22356
|
+
*
|
|
22357
|
+
* 🔴 主会话键**必须一起清**:否则日后用同一个 `accountId` 重建账户时,它会**静默继承**
|
|
22358
|
+
* 那条旧会话 —— 而那条会话的 cwd/角色都是上一代账户的。这个失效**没有任何信号**
|
|
22359
|
+
* (列表上它看着就是一条正常会话)。
|
|
22360
|
+
*/
|
|
22361
|
+
function removeAcpAccount(root, accountId, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22362
|
+
const next = saveAcpAccounts(root, (readImSettings(root, paths).acp.accounts ?? []).filter((a) => a.accountId !== accountId), paths);
|
|
22363
|
+
for (const key of [acpTokenKeyFor(accountId), acpSessionKeyFor(accountId)]) try {
|
|
22364
|
+
unsetEntry(root, "credential", key);
|
|
22365
|
+
} catch {}
|
|
22366
|
+
return next;
|
|
22367
|
+
}
|
|
22002
22368
|
//#endregion
|
|
22003
22369
|
//#region src/acp-face.ts
|
|
22004
22370
|
/** 面名(面宿主幂等键;与 FACE_PORTS 的 id 同名,便于对账) */
|
|
@@ -22016,54 +22382,79 @@ function authorizeUpgrade(req) {
|
|
|
22016
22382
|
ok: false,
|
|
22017
22383
|
reason: "未携带 token(用 Authorization: Bearer <t> 或 ?token=<t>)"
|
|
22018
22384
|
};
|
|
22019
|
-
const roots =
|
|
22385
|
+
const roots = knownAcpRoots();
|
|
22020
22386
|
if (roots.length === 0) return {
|
|
22021
22387
|
ok: false,
|
|
22022
22388
|
reason: "本机没有任何启用了 ACP 通道的 CCC"
|
|
22023
22389
|
};
|
|
22024
|
-
const
|
|
22025
|
-
if (!
|
|
22390
|
+
const acct = resolveTokenAccount(candidate, roots);
|
|
22391
|
+
if (!acct) return {
|
|
22026
22392
|
ok: false,
|
|
22027
|
-
reason: "token
|
|
22393
|
+
reason: "token 不匹配任何已声明账户的 ACP 通道"
|
|
22028
22394
|
};
|
|
22029
|
-
const role =
|
|
22395
|
+
const role = acct.role;
|
|
22030
22396
|
if (!role) return {
|
|
22031
22397
|
ok: false,
|
|
22032
|
-
reason: `CCC(${
|
|
22398
|
+
reason: `CCC(${acct.root})账户 ${acct.accountId} 未配 im.acp.accounts[].role`
|
|
22033
22399
|
};
|
|
22034
22400
|
return {
|
|
22035
22401
|
ok: true,
|
|
22036
|
-
root:
|
|
22037
|
-
role
|
|
22402
|
+
root: acct.root,
|
|
22403
|
+
role,
|
|
22404
|
+
accountId: acct.accountId
|
|
22038
22405
|
};
|
|
22039
22406
|
}
|
|
22040
22407
|
/**
|
|
22041
|
-
*
|
|
22408
|
+
* CCC 根候选的来源(**只要根,不判配置**)。
|
|
22042
22409
|
*
|
|
22043
|
-
* 🔴
|
|
22044
|
-
*
|
|
22045
|
-
*
|
|
22046
|
-
|
|
22410
|
+
* 🔴 v1.59.1 分层变更:**这份名单是"本机有哪些 CCC",不是"谁能连 ACP"**。
|
|
22411
|
+
* "谁能连"由 `expandAcpAccounts` 现算(读各 CCC 的 serenity.json + localstore)。
|
|
22412
|
+
* ⇒ 缓存失效的代价从"配错权限"降为"多看/少看一个候选根",**不再是安全问题**。
|
|
22413
|
+
*/
|
|
22414
|
+
let knownRoots = [];
|
|
22415
|
+
/**
|
|
22416
|
+
* 准入名单的现算入口(v1.59.1)。
|
|
22417
|
+
*
|
|
22418
|
+
* 顺序:**先用已知根**(免 async)→ 逐个 `expandAcpAccounts` 现读配置。
|
|
22419
|
+
* 未知根时退回上次枚举的结果(可能稍旧,但只影响"会不会去问它",不影响是否放行)。
|
|
22420
|
+
*
|
|
22421
|
+
* ⚠️ 这里是**同步**的(upgrade 回调用)⇒ 不 await、不扫盘。
|
|
22422
|
+
*/
|
|
22423
|
+
function knownAcpRoots() {
|
|
22424
|
+
return listAcpRoots(knownRoots);
|
|
22425
|
+
}
|
|
22426
|
+
/**
|
|
22427
|
+
* 刷新"本机有哪些 CCC"(async,枚举宿主三来源)。
|
|
22428
|
+
*
|
|
22429
|
+
* 🔴 **失败必须响亮**:v1.59.0 这里是 `void refresh(...)` 且没有 catch ——
|
|
22430
|
+
* 枚举失败会让名单清空 ⇒ **所有连接一律 401**,而现场只有一行 stdout 看不到的日志。
|
|
22431
|
+
* 这正是本轨记过的「调用没报错 ≠ 已验证」。
|
|
22432
|
+
*
|
|
22433
|
+
* @returns 本次枚举到的根数(0 = 一个都没发现,日志会说为什么)
|
|
22047
22434
|
*/
|
|
22048
|
-
let acpRoots = [];
|
|
22049
|
-
/** 刷新准入名单(**只收启用了 ACP 通道且已生成 token 的 CCC**)。返回新名单长度。 */
|
|
22050
22435
|
async function refreshAcpRoots(ctx) {
|
|
22051
22436
|
try {
|
|
22052
|
-
|
|
22053
|
-
|
|
22054
|
-
|
|
22055
|
-
|
|
22056
|
-
console.log(`[serenity-hooks] ✓ 面 E:已为 CCC 生成 ACP token(${root},指纹 ${tokenFingerprint(token)})`);
|
|
22057
|
-
}
|
|
22437
|
+
knownRoots = (await listCccs(ctx)).map((e) => e.root);
|
|
22438
|
+
let created = 0;
|
|
22439
|
+
for (const root of knownRoots) try {
|
|
22440
|
+
created += ensureAcpChannelTokens(root).length;
|
|
22058
22441
|
} catch (err) {
|
|
22059
|
-
console.warn(`[serenity-hooks] 面 E:为 CCC
|
|
22442
|
+
console.warn(`[serenity-hooks] 面 E:为 CCC(${root})生成 token 失败: ${String(err?.message ?? err)}`);
|
|
22060
22443
|
}
|
|
22061
|
-
|
|
22444
|
+
if (created > 0) console.log(`[serenity-hooks] ✓ 面 E:已为 ${created} 个 ACP 账户生成 token`);
|
|
22445
|
+
return knownRoots.length;
|
|
22062
22446
|
} catch (err) {
|
|
22063
|
-
|
|
22064
|
-
|
|
22447
|
+
knownRoots = [];
|
|
22448
|
+
const msg = `面 E:CCC 枚举失败(准入名单将为空,所有连接会被拒): ${String(err?.message ?? err)}`;
|
|
22449
|
+
console.warn(`[serenity-hooks] ${msg}`);
|
|
22450
|
+
state.lastErrors.push({
|
|
22451
|
+
at: Date.now(),
|
|
22452
|
+
applied: false,
|
|
22453
|
+
error: msg
|
|
22454
|
+
});
|
|
22455
|
+
if (state.lastErrors.length > MAX_ERR_KEEP) state.lastErrors.shift();
|
|
22456
|
+
return 0;
|
|
22065
22457
|
}
|
|
22066
|
-
return acpRoots.length;
|
|
22067
22458
|
}
|
|
22068
22459
|
/**
|
|
22069
22460
|
* 把**本连接归属的 CCC 角色**套到该连接创建的 agent 上(v1.59.0)。
|
|
@@ -22162,13 +22553,58 @@ function acpFaceEnabled() {
|
|
|
22162
22553
|
return readSimpleSettings().acpFaceEnabled;
|
|
22163
22554
|
}
|
|
22164
22555
|
/**
|
|
22556
|
+
* 把客户端给的 `cwd` 归一化成宿主要求的**绝对路径**(v1.59.1)。
|
|
22557
|
+
*
|
|
22558
|
+
* ## 为什么需要
|
|
22559
|
+
* 宿主 `dsh-acp` 在每次 `session/new` 都有这一行(`lib/index.js` 逐字):
|
|
22560
|
+
* ```js
|
|
22561
|
+
* if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
|
|
22562
|
+
* ```
|
|
22563
|
+
* 而**连接归属哪个 CCC 已由 token 决定**(服务端权威)⇒ 让用户再手打一遍绝对路径
|
|
22564
|
+
* 是**同一个信息说两遍**,且那第二遍正是最容易打错的一遍。
|
|
22565
|
+
*
|
|
22566
|
+
* ## 规则(唯一一条)
|
|
22567
|
+
* **非绝对路径 ⇒ 一律解释为「本连接归属 CCC 的根」**。
|
|
22568
|
+
*
|
|
22569
|
+
* | 客户端填 | 送宿主 |
|
|
22570
|
+
* |---|---|
|
|
22571
|
+
* | `serenity` / `a` / `.` / 空 | `/…/home-serenity`(该 token 的 CCC 根) |
|
|
22572
|
+
* | `/…/home-serenity`(已绝对且在根内) | 原样 |
|
|
22573
|
+
* | `/…/home-serenity/sub` | 原样(仍在根内) |
|
|
22574
|
+
* | `/etc`(绝对但在根外) | **原样透传,交给 `cwdInsideRoot` 拒** |
|
|
22575
|
+
*
|
|
22576
|
+
* 🔴 **最后一行为什么不在这里直接拒**:越权判据必须**只有一处**(`cwdInsideRoot`,
|
|
22577
|
+
* 它也管着角色认领),在这里再判一次就是**两份会漂的实现**。归一化只负责"把非绝对变成绝对"。
|
|
22578
|
+
*
|
|
22579
|
+
* @param raw 客户端原样给的 `cwd`(可能是 `undefined`)
|
|
22580
|
+
* @param cccRoot 本连接归属的 CCC 根
|
|
22581
|
+
*/
|
|
22582
|
+
function normalizeClientCwd(raw, cccRoot) {
|
|
22583
|
+
const value = typeof raw === "string" ? raw.trim() : "";
|
|
22584
|
+
if (value === "" || !isAbsolute(value)) return cccRoot;
|
|
22585
|
+
return value;
|
|
22586
|
+
}
|
|
22587
|
+
/**
|
|
22165
22588
|
* 把一条裸 WS socket 适配成 ACP `Stream`。
|
|
22166
22589
|
*
|
|
22167
22590
|
* 🔴 与官方 `ndJsonStream` **同语义**:我们自己做「消息 ↔ 文本帧」的分帧(按行)。
|
|
22168
22591
|
* 实测注意(P0′):`dsh-acp` 写出的每条消息**自带换行**,客户端同样 ⇒ 按行切分即正确。
|
|
22169
22592
|
* ⚠️ **空行要丢**;**非 JSON 帧要容忍**(桥/客户端可能插入控制帧)。
|
|
22593
|
+
*
|
|
22594
|
+
* 🔴 v1.59.1:**入站消息在这里做一次 `cwd` 归一化**(见 {@link normalizeClientCwd})。
|
|
22595
|
+
* 为什么是这里:`session/new` 与 `session/resume` 都带 `cwd`,而这是**唯一**能同时
|
|
22596
|
+
* 拦到两者、且属于我们自己的缝(再往下就进 `dsh-acp` 了,那里改了就是改宿主)。
|
|
22597
|
+
*
|
|
22598
|
+
* 🔴 v1.59.2:这条缝同时承担 **owner「每账户固定一条长期对话」** 的双向改写 ——
|
|
22599
|
+
* 入站 `recordAcpPending`(记 id→method)+ 出站 `rewriteAcpResponse`(收窄 list / 记 new)。
|
|
22600
|
+
* ⚠️ 两件事都必须在**同一个** `ws` 上做:`pending` 是**每条连接一份**的,
|
|
22601
|
+
* 拆到两处会让它失去"这条连接上请求与响应配对"这个唯一用途。
|
|
22602
|
+
*
|
|
22603
|
+
* @param cccRoot 本连接归属的 CCC 根(token 反查所得)
|
|
22604
|
+
* @param accountId 本连接归属的账户键(主会话键由它派生)
|
|
22170
22605
|
*/
|
|
22171
|
-
function wsToAcpStream(ws, onWarn) {
|
|
22606
|
+
function wsToAcpStream(ws, onWarn, cccRoot, accountId) {
|
|
22607
|
+
const pin = createAcpSessionPin(cccRoot, accountId);
|
|
22172
22608
|
return {
|
|
22173
22609
|
readable: new ReadableStream({ start(controller) {
|
|
22174
22610
|
let buf = "";
|
|
@@ -22179,11 +22615,15 @@ function wsToAcpStream(ws, onWarn) {
|
|
|
22179
22615
|
const line = buf.slice(0, i);
|
|
22180
22616
|
buf = buf.slice(i + 1);
|
|
22181
22617
|
if (!line.trim()) continue;
|
|
22618
|
+
let msg;
|
|
22182
22619
|
try {
|
|
22183
|
-
|
|
22620
|
+
msg = JSON.parse(line);
|
|
22184
22621
|
} catch {
|
|
22185
22622
|
onWarn(line.slice(0, 120));
|
|
22623
|
+
continue;
|
|
22186
22624
|
}
|
|
22625
|
+
recordAcpPending(pin, msg);
|
|
22626
|
+
controller.enqueue(normalizeInboundCwd(msg, cccRoot));
|
|
22187
22627
|
}
|
|
22188
22628
|
});
|
|
22189
22629
|
ws.on("close", () => {
|
|
@@ -22198,10 +22638,133 @@ function wsToAcpStream(ws, onWarn) {
|
|
|
22198
22638
|
});
|
|
22199
22639
|
} }),
|
|
22200
22640
|
writable: new WritableStream({ write(msg) {
|
|
22201
|
-
ws.send(`${JSON.stringify(msg)}\n`);
|
|
22641
|
+
ws.send(`${JSON.stringify(rewriteAcpResponse(pin, msg))}\n`);
|
|
22202
22642
|
} })
|
|
22203
22643
|
};
|
|
22204
22644
|
}
|
|
22645
|
+
/**
|
|
22646
|
+
* 需要把 `cwd` 钉到本连接 CCC 根的方法(**必须逐个列出**)。
|
|
22647
|
+
*
|
|
22648
|
+
* 🔴 **v1.59.1 的第一版判据错了**:只判「params 里有没有 `cwd`」⇒ 漏掉 `session/list`。
|
|
22649
|
+
* 宿主 `listSessions` 的过滤是
|
|
22650
|
+
* `if (params.cwd !== void 0 && !sameDirectory(header.cwd, params.cwd)) return`
|
|
22651
|
+
* —— **不给 cwd 就返回全机器所有会话**。实测后果:手机 App 列表里冒出
|
|
22652
|
+
* 23 条别的 CCC 的会话,全部显示为 `UNKNOWN` / `New Chat`
|
|
22653
|
+
* (owner:「**我需要是指定CCC的,不然没办法用**」)。
|
|
22654
|
+
* ⇒ 🆕 **判据:凡"应当被 CCC 收窄"的读接口,都必须把 `cwd` 钉成 CCC 根,不能依赖客户端传** ——
|
|
22655
|
+
* token 已经定了 CCC,让客户端再声明一次既冗余、又可以被静默漏掉。
|
|
22656
|
+
*/
|
|
22657
|
+
const CWD_PINNED_METHODS = /* @__PURE__ */ new Set([
|
|
22658
|
+
"session/new",
|
|
22659
|
+
"session/resume",
|
|
22660
|
+
"session/list"
|
|
22661
|
+
]);
|
|
22662
|
+
/**
|
|
22663
|
+
* 把一条入站消息的 `cwd` 归一化(**含"没给就补上"**)。
|
|
22664
|
+
*
|
|
22665
|
+
* | 情形 | 结果 |
|
|
22666
|
+
* |---|---|
|
|
22667
|
+
* | 方法不在 {@link CWD_PINNED_METHODS} | 原样(不碰别的消息) |
|
|
22668
|
+
* | 给了非绝对路径(`serenity` / `a` / 空串) | → 本连接 CCC 根 |
|
|
22669
|
+
* | 给了绝对路径 | 原样(越界由 `cwdInsideRoot` 拒) |
|
|
22670
|
+
* | **压根没给**(`session/list` 常见) | **→ 本连接 CCC 根**(否则列出全机器) |
|
|
22671
|
+
*/
|
|
22672
|
+
function normalizeInboundCwd(msg, cccRoot) {
|
|
22673
|
+
if (typeof msg !== "object" || msg === null) return msg;
|
|
22674
|
+
const m = msg;
|
|
22675
|
+
if (typeof m.method !== "string" || !CWD_PINNED_METHODS.has(m.method)) return msg;
|
|
22676
|
+
const params = typeof m.params === "object" && m.params !== null ? m.params : {};
|
|
22677
|
+
return {
|
|
22678
|
+
...msg,
|
|
22679
|
+
params: {
|
|
22680
|
+
...params,
|
|
22681
|
+
cwd: normalizeClientCwd(params.cwd, cccRoot)
|
|
22682
|
+
}
|
|
22683
|
+
};
|
|
22684
|
+
}
|
|
22685
|
+
/** 请求 id 的 Map 键(区分 `1` 与 `"1"` —— JSON-RPC 允许两者,混用也不该串号) */
|
|
22686
|
+
function requestIdKey(id) {
|
|
22687
|
+
return `${typeof id}:${String(id)}`;
|
|
22688
|
+
}
|
|
22689
|
+
/** 记账上限(防"客户端发一堆请求又从不收响应"把内存撑大) */
|
|
22690
|
+
const MAX_PENDING = 256;
|
|
22691
|
+
/** 建一条连接的钉住状态 */
|
|
22692
|
+
function createAcpSessionPin(root, accountId) {
|
|
22693
|
+
return {
|
|
22694
|
+
root,
|
|
22695
|
+
accountId,
|
|
22696
|
+
pending: /* @__PURE__ */ new Map()
|
|
22697
|
+
};
|
|
22698
|
+
}
|
|
22699
|
+
/**
|
|
22700
|
+
* 入站记账:把"这条请求的方法"记住,供出站按 `id` 反查。
|
|
22701
|
+
*
|
|
22702
|
+
* 判据:**既要有 `id`,又要有 `method`** —— 只满足一个的是响应(`id` 无 `method`)
|
|
22703
|
+
* 或通知(`method` 无 `id`)⇒ 与我们无关,不记。
|
|
22704
|
+
*/
|
|
22705
|
+
function recordAcpPending(pin, msg) {
|
|
22706
|
+
if (typeof msg !== "object" || msg === null) return;
|
|
22707
|
+
const m = msg;
|
|
22708
|
+
if (m.id === void 0 || m.id === null) return;
|
|
22709
|
+
if (typeof m.method !== "string") return;
|
|
22710
|
+
const key = requestIdKey(m.id);
|
|
22711
|
+
pin.pending.set(key, m.method);
|
|
22712
|
+
while (pin.pending.size > MAX_PENDING) {
|
|
22713
|
+
const oldest = pin.pending.keys().next();
|
|
22714
|
+
if (oldest.done) break;
|
|
22715
|
+
pin.pending.delete(oldest.value);
|
|
22716
|
+
}
|
|
22717
|
+
}
|
|
22718
|
+
/**
|
|
22719
|
+
* 出站改写(本功能的落点)。返回**要发给客户端的那条消息**。
|
|
22720
|
+
*
|
|
22721
|
+
* | 原响应 | 改写后 |
|
|
22722
|
+
* |---|---|
|
|
22723
|
+
* | `session/list` | `{sessions:[该账户那一条]}`;**没有主会话 ⇒ `{sessions:[]}`** |
|
|
22724
|
+
* | `session/new` | 原样,**同时**把 `result.sessionId` 记为该账户的主会话 |
|
|
22725
|
+
* | 其余(含所有 error 响应、所有通知) | 原样 |
|
|
22726
|
+
*
|
|
22727
|
+
* 🔴 **没有主会话时回空数组,绝不回退成全量列表** —— 退回去正是 owner 说
|
|
22728
|
+
* 「23 个对我没什么用」的那个东西。空数组会让 App 引导用户新建,而新建即建立主会话。
|
|
22729
|
+
*
|
|
22730
|
+
* 🔴 `nextCursor` **一律丢掉**:收窄到一条之后,分页游标失去意义;
|
|
22731
|
+
* 留着它会让客户端继续翻页(而后续页仍会被收窄成同一条 ⇒ 前端可能出现重复项)。
|
|
22732
|
+
*/
|
|
22733
|
+
function rewriteAcpResponse(pin, msg) {
|
|
22734
|
+
if (typeof msg !== "object" || msg === null) return msg;
|
|
22735
|
+
const m = msg;
|
|
22736
|
+
if (m.method !== void 0) return msg;
|
|
22737
|
+
const method = m.id === void 0 || m.id === null ? void 0 : pin.pending.get(requestIdKey(m.id));
|
|
22738
|
+
if (method === void 0) return msg;
|
|
22739
|
+
if (m.error !== void 0) return msg;
|
|
22740
|
+
if (method === "session/new") {
|
|
22741
|
+
const sessionId = m.result?.sessionId;
|
|
22742
|
+
if (typeof sessionId === "string" && sessionId.trim() !== "") try {
|
|
22743
|
+
writeAcpMainSession(pin.root, pin.accountId, sessionId);
|
|
22744
|
+
} catch (err) {
|
|
22745
|
+
console.warn(`[serenity-hooks] ⚠ 面 E:记录主会话失败(CCC=${pin.root} 账户=${pin.accountId}): ${String(err?.message ?? err)}`);
|
|
22746
|
+
}
|
|
22747
|
+
return msg;
|
|
22748
|
+
}
|
|
22749
|
+
if (method === "session/list") {
|
|
22750
|
+
const pinned = readAcpMainSession(pin.root, pin.accountId);
|
|
22751
|
+
if (!pinned) return {
|
|
22752
|
+
...m,
|
|
22753
|
+
result: { sessions: [] }
|
|
22754
|
+
};
|
|
22755
|
+
const list = m.result?.sessions;
|
|
22756
|
+
const hit = Array.isArray(list) ? list.find((s) => s?.sessionId === pinned) : void 0;
|
|
22757
|
+
const sessions = hit !== void 0 ? [hit] : [{
|
|
22758
|
+
sessionId: pinned,
|
|
22759
|
+
cwd: pin.root
|
|
22760
|
+
}];
|
|
22761
|
+
return {
|
|
22762
|
+
...m,
|
|
22763
|
+
result: { sessions }
|
|
22764
|
+
};
|
|
22765
|
+
}
|
|
22766
|
+
return msg;
|
|
22767
|
+
}
|
|
22205
22768
|
function newState() {
|
|
22206
22769
|
return {
|
|
22207
22770
|
handle: null,
|
|
@@ -22282,9 +22845,10 @@ ACP 面:认证失败(${verdict.reason})。请在该 CCC 的 localstore 生
|
|
|
22282
22845
|
try {
|
|
22283
22846
|
const connRoot = verdict.root;
|
|
22284
22847
|
const connRole = verdict.role;
|
|
22848
|
+
const connAccount = verdict.accountId;
|
|
22285
22849
|
const stream = wsToAcpStream(ws, (line) => {
|
|
22286
22850
|
console.log(`[serenity-hooks] 面 E:丢弃一条非 JSON 帧(前 120 字): ${line}`);
|
|
22287
|
-
});
|
|
22851
|
+
}, connRoot, connAccount);
|
|
22288
22852
|
const fiber = ctx.plugin({
|
|
22289
22853
|
inject: ACP_REQUIRED_SERVICES,
|
|
22290
22854
|
apply: (scoped) => {
|
|
@@ -22451,7 +23015,7 @@ function registerAcpFace(ctx) {
|
|
|
22451
23015
|
} catch {}
|
|
22452
23016
|
const refresh = () => {
|
|
22453
23017
|
refreshAcpRoots(ctx).then((n) => {
|
|
22454
|
-
console.log(`[serenity-hooks] 面 E:CCC
|
|
23018
|
+
console.log(`[serenity-hooks] 面 E:CCC 候选根已枚举(${n} 个)`);
|
|
22455
23019
|
});
|
|
22456
23020
|
};
|
|
22457
23021
|
try {
|