@shgroup/dsh-serenity-hooks 1.59.0 → 1.59.1
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 +115 -39
- package/lib/acp-face.d.ts +42 -3
- package/lib/ccc.d.ts +28 -5
- package/lib/client.js +299 -5
- package/lib/index.js +467 -68
- 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.2: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,56 @@ 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
|
+
acpTokenKeyFor: () => acpTokenKeyFor,
|
|
21957
|
+
cwdInsideRoot: () => cwdInsideRoot,
|
|
21958
|
+
ensureAcpChannelTokens: () => ensureAcpChannelTokens,
|
|
21959
|
+
ensureAcpToken: () => ensureAcpToken,
|
|
21960
|
+
expandAcpAccounts: () => expandAcpAccounts,
|
|
21961
|
+
extractToken: () => extractToken,
|
|
21962
|
+
listAcpRoots: () => listAcpRoots,
|
|
21963
|
+
normalizeAcpAccount: () => normalizeAcpAccount,
|
|
21964
|
+
readAcpToken: () => readAcpToken,
|
|
21965
|
+
readImSettings: () => readImSettings,
|
|
21966
|
+
removeAcpAccount: () => removeAcpAccount,
|
|
21967
|
+
resetAcpToken: () => resetAcpToken,
|
|
21968
|
+
resolveTokenAccount: () => resolveTokenAccount,
|
|
21969
|
+
safeEqual: () => safeEqual,
|
|
21970
|
+
saveAcpAccounts: () => saveAcpAccounts,
|
|
21971
|
+
tokenFingerprint: () => tokenFingerprint,
|
|
21972
|
+
upsertAcpAccount: () => upsertAcpAccount
|
|
21973
|
+
});
|
|
21974
|
+
/** 多账户凭据键前缀(`IM_ACP_TOKEN_<ACCOUNT_ID>`;与微信桥 `WEIXIN_<ID>_<FIELD>` 同构) */
|
|
21975
|
+
const ACP_TOKEN_PREFIX = "IM_ACP_TOKEN_";
|
|
21976
|
+
/**
|
|
21977
|
+
* `accountId` → UPPER_SNAKE 片段(`agmente-phone` → `AGMENTE_PHONE`)。
|
|
21978
|
+
*
|
|
21979
|
+
* 🔴 **与 `weixin-route.ts` 的 `accountKeyPart` 逐字同规则** —— 刻意复制而非共享:
|
|
21980
|
+
* 两者属不同子域(微信 vs ACP),共享会让"改微信键规则"意外改掉 ACP 的既有凭据键,
|
|
21981
|
+
* 而凭据键**改一次就等于全体客户端掉线**。⇒ 用"同规则的两份"换"互不牵连"。
|
|
21982
|
+
*/
|
|
21983
|
+
function accountKeyPart(accountId) {
|
|
21984
|
+
return accountId.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
|
|
21985
|
+
}
|
|
21986
|
+
/** 该账户的凭据键(`IM_ACP_TOKEN_<ACCOUNT_ID>`);`accountId` 必填 */
|
|
21987
|
+
function acpTokenKeyFor(accountId) {
|
|
21988
|
+
return `${ACP_TOKEN_PREFIX}${accountKeyPart(accountId)}`;
|
|
21989
|
+
}
|
|
21858
21990
|
/** token 字节数(32 B = 256 bit;hex 后 64 字符) */
|
|
21859
21991
|
const TOKEN_BYTES = 32;
|
|
21860
21992
|
/**
|
|
21861
|
-
*
|
|
21993
|
+
* 该**账户**的 ACP token(**读**;不存在返回 null)。
|
|
21994
|
+
*
|
|
21995
|
+
* 🔴 **不自动生成** —— 生成是**写操作**,只在显式要求时做(`ensureAcpToken`)。
|
|
21996
|
+
* 读路径永远无副作用:否则每次判定都会改 localstore。
|
|
21862
21997
|
*
|
|
21863
|
-
*
|
|
21864
|
-
* (`ensureAcpToken`)。读路径永远无副作用:否则每次启动都会改 localstore。
|
|
21998
|
+
* @param accountId 账户键(必填 —— 凭据按账户分离,没有"CCC 默认 token"这回事)
|
|
21865
21999
|
*/
|
|
21866
|
-
function readAcpToken(root) {
|
|
22000
|
+
function readAcpToken(root, accountId) {
|
|
21867
22001
|
try {
|
|
21868
|
-
const value = getEntry(root, "credential",
|
|
22002
|
+
const value = getEntry(root, "credential", acpTokenKeyFor(accountId));
|
|
21869
22003
|
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
21870
22004
|
} catch {
|
|
21871
22005
|
return null;
|
|
@@ -21876,12 +22010,23 @@ function readAcpToken(root) {
|
|
|
21876
22010
|
*
|
|
21877
22011
|
* ⚠️ **幂等**:已有 token 时**原样返回**(不轮换)—— 轮换会让在用的客户端立刻断线,
|
|
21878
22012
|
* 必须是一次显式动作(`resetAcpToken`)。
|
|
22013
|
+
*
|
|
22014
|
+
* @param accountId 多账户时指明哪个账户;省略 = 单账户简写键
|
|
21879
22015
|
*/
|
|
21880
|
-
function ensureAcpToken(root) {
|
|
21881
|
-
const existing = readAcpToken(root);
|
|
22016
|
+
function ensureAcpToken(root, accountId) {
|
|
22017
|
+
const existing = readAcpToken(root, accountId);
|
|
21882
22018
|
if (existing) return existing;
|
|
21883
22019
|
const token = randomBytes(TOKEN_BYTES).toString("hex");
|
|
21884
|
-
writeEntry(root, "credential",
|
|
22020
|
+
writeEntry(root, "credential", acpTokenKeyFor(accountId), token);
|
|
22021
|
+
return token;
|
|
22022
|
+
}
|
|
22023
|
+
/**
|
|
22024
|
+
* 轮换 token(显式动作;返回新值)。旧 token 立即失效。
|
|
22025
|
+
* @param accountId 账户键
|
|
22026
|
+
*/
|
|
22027
|
+
function resetAcpToken(root, accountId) {
|
|
22028
|
+
const token = randomBytes(TOKEN_BYTES).toString("hex");
|
|
22029
|
+
writeEntry(root, "credential", acpTokenKeyFor(accountId), token);
|
|
21885
22030
|
return token;
|
|
21886
22031
|
}
|
|
21887
22032
|
/**
|
|
@@ -21922,32 +22067,92 @@ function extractToken(headers, url) {
|
|
|
21922
22067
|
return null;
|
|
21923
22068
|
}
|
|
21924
22069
|
/**
|
|
21925
|
-
*
|
|
22070
|
+
* 把该 CCC 的配置**展开成账户列表**(读时现算,**无快照**)。
|
|
22071
|
+
*
|
|
22072
|
+
* 🔴 **`im.acp.accounts` 是必填项** —— 没有它就没有账户,**一个都认证不了**。
|
|
22073
|
+
* 刻意**不提供**"单账户简写"退回路径(v1.59.1):那会多出一条永不生效的分支,
|
|
22074
|
+
* 而它的唯一作用是把"配置写错了"伪装成"用了简写"。
|
|
22075
|
+
*
|
|
22076
|
+
* 判据(三条都由调用方以 fail-closed 消费):
|
|
22077
|
+
* - `enabled !== false`(缺省 true)
|
|
22078
|
+
* - `role` 非空(空 ⇒ 该账户**不出现**在列表里 ⇒ 必然登录失败)
|
|
22079
|
+
* - token 存在(不存在 ⇒ 同样不出现)
|
|
22080
|
+
*
|
|
22081
|
+
* ⚠️ 本函数**纯读,绝不生成 token** —— 生成是写操作,归 `ensureAcpChannelTokens`。
|
|
22082
|
+
* 这样 upgrade 路径可以随时现算而不冒"每次连接都写 localstore"的风险。
|
|
22083
|
+
*/
|
|
22084
|
+
function expandAcpAccounts(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22085
|
+
const im = readImSettings(root, paths);
|
|
22086
|
+
if (im.channel !== "acp" || im.acp.enabled !== true) return [];
|
|
22087
|
+
const declared = Array.isArray(im.acp.accounts) ? im.acp.accounts : [];
|
|
22088
|
+
const out = [];
|
|
22089
|
+
for (const a of declared) {
|
|
22090
|
+
if (typeof a !== "object" || a === null) continue;
|
|
22091
|
+
if (a.enabled === false) continue;
|
|
22092
|
+
const accountId = typeof a.accountId === "string" ? a.accountId.trim() : "";
|
|
22093
|
+
if (accountId === "") continue;
|
|
22094
|
+
const role = typeof a.role === "string" ? a.role.trim() : "";
|
|
22095
|
+
if (role === "") continue;
|
|
22096
|
+
const token = readAcpToken(root, accountId);
|
|
22097
|
+
if (!token) continue;
|
|
22098
|
+
out.push({
|
|
22099
|
+
root,
|
|
22100
|
+
accountId,
|
|
22101
|
+
tokenKey: acpTokenKeyFor(accountId),
|
|
22102
|
+
token,
|
|
22103
|
+
role
|
|
22104
|
+
});
|
|
22105
|
+
}
|
|
22106
|
+
return out;
|
|
22107
|
+
}
|
|
22108
|
+
/**
|
|
22109
|
+
* **让配置里声明的账户都拥有 token**(写操作;幂等;由装配层与配置变更时调用)。
|
|
22110
|
+
*
|
|
22111
|
+
* 🔴 owner 令「**随即生成**认证用的 token」的落地点。与 v1.59.0 的差别:
|
|
22112
|
+
* 那时只在**启动**时跑一次 ⇒ 加一台设备要重启;现在**任何一次判定前都可以跑**,
|
|
22113
|
+
* 且撤账户后不会被"复活"(只对**当前声明里**的账户生成)。
|
|
22114
|
+
*
|
|
22115
|
+
* @returns 本次**新生成**的账户键列表(已存在的原样返回,不轮换)
|
|
22116
|
+
*/
|
|
22117
|
+
function ensureAcpChannelTokens(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22118
|
+
const im = readImSettings(root, paths);
|
|
22119
|
+
if (im.channel !== "acp" || im.acp.enabled !== true) return [];
|
|
22120
|
+
const declared = Array.isArray(im.acp.accounts) ? im.acp.accounts : [];
|
|
22121
|
+
const created = [];
|
|
22122
|
+
for (const a of declared) {
|
|
22123
|
+
if (typeof a !== "object" || a === null) continue;
|
|
22124
|
+
if (a.enabled === false) continue;
|
|
22125
|
+
const accountId = typeof a.accountId === "string" ? a.accountId.trim() : "";
|
|
22126
|
+
if (accountId === "") continue;
|
|
22127
|
+
try {
|
|
22128
|
+
if (!readAcpToken(root, accountId)) {
|
|
22129
|
+
ensureAcpToken(root, accountId);
|
|
22130
|
+
created.push(accountId);
|
|
22131
|
+
}
|
|
22132
|
+
} catch {}
|
|
22133
|
+
}
|
|
22134
|
+
return created;
|
|
22135
|
+
}
|
|
22136
|
+
/**
|
|
22137
|
+
* 校验一枚候选 token,返回它归属的**账户**(未命中返回 null)。
|
|
21926
22138
|
*
|
|
21927
22139
|
* @param candidate 客户端出示的 token
|
|
21928
22140
|
* @param roots 候选 CCC 根集合(由调用方枚举——本模块不认识"CCC 在哪")
|
|
21929
22141
|
*/
|
|
21930
|
-
function
|
|
22142
|
+
function resolveTokenAccount(candidate, roots, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
21931
22143
|
if (!candidate) return null;
|
|
21932
|
-
for (const root of roots)
|
|
21933
|
-
const token = readAcpToken(root);
|
|
21934
|
-
if (token && safeEqual(candidate, token)) return root;
|
|
21935
|
-
}
|
|
22144
|
+
for (const root of roots) for (const acct of expandAcpAccounts(root, paths)) if (safeEqual(candidate, acct.token)) return acct;
|
|
21936
22145
|
return null;
|
|
21937
22146
|
}
|
|
21938
22147
|
/**
|
|
21939
|
-
*
|
|
22148
|
+
* 枚举**有可用 ACP 账户**的 CCC(供准入名单用)。
|
|
21940
22149
|
*
|
|
21941
|
-
*
|
|
21942
|
-
*
|
|
22150
|
+
* 判据:`im.channel === 'acp'` ∧ `im.acp.enabled === true` ∧ **至少一个账户有 token 且配了角色**。
|
|
22151
|
+
* ⚠️ 缺一即不算(fail-closed:宁可连不上,不可放行一个半配置的通道)。
|
|
21943
22152
|
*/
|
|
21944
22153
|
function listAcpRoots(roots, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
21945
22154
|
const out = [];
|
|
21946
|
-
for (const root of roots)
|
|
21947
|
-
if (!acpChannelEnabled(root, paths)) continue;
|
|
21948
|
-
if (!readAcpToken(root)) continue;
|
|
21949
|
-
out.push(root);
|
|
21950
|
-
}
|
|
22155
|
+
for (const root of roots) if (expandAcpAccounts(root, paths).length > 0) out.push(root);
|
|
21951
22156
|
return out;
|
|
21952
22157
|
}
|
|
21953
22158
|
/** 读该 CCC 的 `im` 段(未配置 ⇒ 归一缺省;永不 undefined) */
|
|
@@ -21980,11 +22185,6 @@ function acpChannelEnabled(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
|
21980
22185
|
const im = readImSettings(root, paths);
|
|
21981
22186
|
return im.channel === "acp" && im.acp.enabled === true;
|
|
21982
22187
|
}
|
|
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
22188
|
/**
|
|
21989
22189
|
* 客户端 `session/new` 传来的 `cwd` 是否落在该 token 归属的 CCC 内。
|
|
21990
22190
|
*
|
|
@@ -21999,6 +22199,119 @@ function cwdInsideRoot(cwd, root) {
|
|
|
21999
22199
|
const rel = relative(resolve(root), resolve(cwd));
|
|
22000
22200
|
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
22001
22201
|
}
|
|
22202
|
+
/** 该 CCC 的 ACP 通道对外**可见形态**(诊断面用;**绝不含 token 明文**) */
|
|
22203
|
+
function acpChannelStatus(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22204
|
+
const im = readImSettings(root, paths);
|
|
22205
|
+
const declared = Array.isArray(im.acp.accounts) ? im.acp.accounts : [];
|
|
22206
|
+
const usable = new Set(expandAcpAccounts(root, paths).map((a) => a.accountId));
|
|
22207
|
+
const accounts = [];
|
|
22208
|
+
for (const a of declared) {
|
|
22209
|
+
if (typeof a !== "object" || a === null) continue;
|
|
22210
|
+
const accountId = typeof a.accountId === "string" ? a.accountId.trim() : "";
|
|
22211
|
+
if (accountId === "") continue;
|
|
22212
|
+
const role = typeof a.role === "string" && a.role.trim() !== "" ? a.role.trim() : null;
|
|
22213
|
+
const token = readAcpToken(root, accountId);
|
|
22214
|
+
const enabled = a.enabled !== false;
|
|
22215
|
+
let blockedBy = null;
|
|
22216
|
+
if (!enabled) blockedBy = "该账户 enabled=false";
|
|
22217
|
+
else if (!role) blockedBy = "未配 role";
|
|
22218
|
+
else if (!token) blockedBy = "尚无 token(保存配置后自动生成)";
|
|
22219
|
+
accounts.push({
|
|
22220
|
+
accountId,
|
|
22221
|
+
name: typeof a.name === "string" && a.name.trim() !== "" ? a.name.trim() : null,
|
|
22222
|
+
role,
|
|
22223
|
+
enabled,
|
|
22224
|
+
hasToken: token !== null,
|
|
22225
|
+
tokenFingerprint: token ? tokenFingerprint(token) : null,
|
|
22226
|
+
usable: usable.has(accountId),
|
|
22227
|
+
blockedBy
|
|
22228
|
+
});
|
|
22229
|
+
}
|
|
22230
|
+
return {
|
|
22231
|
+
channel: im.channel,
|
|
22232
|
+
enabled: acpChannelEnabled(root, paths),
|
|
22233
|
+
accounts
|
|
22234
|
+
};
|
|
22235
|
+
}
|
|
22236
|
+
/**
|
|
22237
|
+
* 读取 serenity.json 原文(不存在 → 空对象)。
|
|
22238
|
+
*
|
|
22239
|
+
* ⚠️ 与 `weixin-route.ts` 的同名函数**刻意各写一份**:两者写的是**不同段**
|
|
22240
|
+
* (`weixin` vs `im.acp.accounts`),共用一个会在"某天只想改其中一段"时
|
|
22241
|
+
* 意外把另一段也重写一遍。同规则两份 > 一个会互相牵连的共用件。
|
|
22242
|
+
*/
|
|
22243
|
+
function readRawConfig(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22244
|
+
for (const candidate of paths) {
|
|
22245
|
+
const p = resolve(root, candidate);
|
|
22246
|
+
if (existsSync(p)) try {
|
|
22247
|
+
const v = JSON.parse(readFileSync(p, "utf-8").replace(/^\uFEFF/, ""));
|
|
22248
|
+
if (v && typeof v === "object" && !Array.isArray(v)) return v;
|
|
22249
|
+
} catch {
|
|
22250
|
+
return {};
|
|
22251
|
+
}
|
|
22252
|
+
}
|
|
22253
|
+
return {};
|
|
22254
|
+
}
|
|
22255
|
+
/**
|
|
22256
|
+
* 写回 `im.acp.accounts`(保留文件里其余所有字段)。
|
|
22257
|
+
*
|
|
22258
|
+
* 🔴 **只碰这一个键**:`im.channel` / `im.acp.enabled` 归面板总开关与人工配置,
|
|
22259
|
+
* 本函数不越权改它们(否则"加一台设备"会顺手把通道开关也改了)。
|
|
22260
|
+
*/
|
|
22261
|
+
function saveAcpAccounts(root, accounts, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22262
|
+
const target = resolve(root, paths[0] ?? ".opencode/serenity.json");
|
|
22263
|
+
const raw = readRawConfig(root, paths);
|
|
22264
|
+
const im = raw.im && typeof raw.im === "object" ? raw.im : {};
|
|
22265
|
+
const acp = im.acp && typeof im.acp === "object" ? im.acp : {};
|
|
22266
|
+
acp.accounts = accounts;
|
|
22267
|
+
im.acp = acp;
|
|
22268
|
+
raw.im = im;
|
|
22269
|
+
const dir = resolve(target, "..");
|
|
22270
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
22271
|
+
writeFileSync(target, JSON.stringify(raw, null, 2) + "\n", "utf-8");
|
|
22272
|
+
return accounts;
|
|
22273
|
+
}
|
|
22274
|
+
/**
|
|
22275
|
+
* 规范化一条账户声明(面板传入的任意形状 → 合法声明;非法 ⇒ null)。
|
|
22276
|
+
*
|
|
22277
|
+
* 🔴 返回类型里 `role` 是**必填**(不是 `ImAcpAccount` 的可选形态)——
|
|
22278
|
+
* 本函数的契约就是"要么给一个角色齐备的账户,要么给 null"。
|
|
22279
|
+
* 这样调用方**不必再判一次** `role` 有没有(那正是会漂的第二处判据)。
|
|
22280
|
+
*/
|
|
22281
|
+
function normalizeAcpAccount(input) {
|
|
22282
|
+
if (typeof input !== "object" || input === null) return null;
|
|
22283
|
+
const a = input;
|
|
22284
|
+
const accountId = typeof a.accountId === "string" ? a.accountId.trim() : "";
|
|
22285
|
+
if (accountId === "") return null;
|
|
22286
|
+
const role = typeof a.role === "string" ? a.role.trim() : "";
|
|
22287
|
+
if (role === "") return null;
|
|
22288
|
+
const out = {
|
|
22289
|
+
accountId,
|
|
22290
|
+
role
|
|
22291
|
+
};
|
|
22292
|
+
if (typeof a.name === "string" && a.name.trim() !== "") out.name = a.name.trim();
|
|
22293
|
+
if (a.enabled === false) out.enabled = false;
|
|
22294
|
+
return out;
|
|
22295
|
+
}
|
|
22296
|
+
/** 增/改一条账户(按 accountId 定位;写入后返回全表) */
|
|
22297
|
+
function upsertAcpAccount(root, account, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22298
|
+
const list = [...readImSettings(root, paths).acp.accounts ?? []];
|
|
22299
|
+
const idx = list.findIndex((a) => a.accountId === account.accountId);
|
|
22300
|
+
if (idx >= 0) list[idx] = {
|
|
22301
|
+
...list[idx],
|
|
22302
|
+
...account
|
|
22303
|
+
};
|
|
22304
|
+
else list.push(account);
|
|
22305
|
+
return saveAcpAccounts(root, list, paths);
|
|
22306
|
+
}
|
|
22307
|
+
/** 删一条账户(**同时清掉它的 token**,避免留死键) */
|
|
22308
|
+
function removeAcpAccount(root, accountId, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
22309
|
+
const next = saveAcpAccounts(root, (readImSettings(root, paths).acp.accounts ?? []).filter((a) => a.accountId !== accountId), paths);
|
|
22310
|
+
try {
|
|
22311
|
+
unsetEntry(root, "credential", acpTokenKeyFor(accountId));
|
|
22312
|
+
} catch {}
|
|
22313
|
+
return next;
|
|
22314
|
+
}
|
|
22002
22315
|
//#endregion
|
|
22003
22316
|
//#region src/acp-face.ts
|
|
22004
22317
|
/** 面名(面宿主幂等键;与 FACE_PORTS 的 id 同名,便于对账) */
|
|
@@ -22016,54 +22329,79 @@ function authorizeUpgrade(req) {
|
|
|
22016
22329
|
ok: false,
|
|
22017
22330
|
reason: "未携带 token(用 Authorization: Bearer <t> 或 ?token=<t>)"
|
|
22018
22331
|
};
|
|
22019
|
-
const roots =
|
|
22332
|
+
const roots = knownAcpRoots();
|
|
22020
22333
|
if (roots.length === 0) return {
|
|
22021
22334
|
ok: false,
|
|
22022
22335
|
reason: "本机没有任何启用了 ACP 通道的 CCC"
|
|
22023
22336
|
};
|
|
22024
|
-
const
|
|
22025
|
-
if (!
|
|
22337
|
+
const acct = resolveTokenAccount(candidate, roots);
|
|
22338
|
+
if (!acct) return {
|
|
22026
22339
|
ok: false,
|
|
22027
|
-
reason: "token
|
|
22340
|
+
reason: "token 不匹配任何已声明账户的 ACP 通道"
|
|
22028
22341
|
};
|
|
22029
|
-
const role =
|
|
22342
|
+
const role = acct.role;
|
|
22030
22343
|
if (!role) return {
|
|
22031
22344
|
ok: false,
|
|
22032
|
-
reason: `CCC(${
|
|
22345
|
+
reason: `CCC(${acct.root})账户 ${acct.accountId} 未配 im.acp.accounts[].role`
|
|
22033
22346
|
};
|
|
22034
22347
|
return {
|
|
22035
22348
|
ok: true,
|
|
22036
|
-
root:
|
|
22037
|
-
role
|
|
22349
|
+
root: acct.root,
|
|
22350
|
+
role,
|
|
22351
|
+
accountId: acct.accountId
|
|
22038
22352
|
};
|
|
22039
22353
|
}
|
|
22040
22354
|
/**
|
|
22041
|
-
*
|
|
22355
|
+
* CCC 根候选的来源(**只要根,不判配置**)。
|
|
22042
22356
|
*
|
|
22043
|
-
* 🔴
|
|
22044
|
-
*
|
|
22045
|
-
*
|
|
22046
|
-
|
|
22357
|
+
* 🔴 v1.59.1 分层变更:**这份名单是"本机有哪些 CCC",不是"谁能连 ACP"**。
|
|
22358
|
+
* "谁能连"由 `expandAcpAccounts` 现算(读各 CCC 的 serenity.json + localstore)。
|
|
22359
|
+
* ⇒ 缓存失效的代价从"配错权限"降为"多看/少看一个候选根",**不再是安全问题**。
|
|
22360
|
+
*/
|
|
22361
|
+
let knownRoots = [];
|
|
22362
|
+
/**
|
|
22363
|
+
* 准入名单的现算入口(v1.59.1)。
|
|
22364
|
+
*
|
|
22365
|
+
* 顺序:**先用已知根**(免 async)→ 逐个 `expandAcpAccounts` 现读配置。
|
|
22366
|
+
* 未知根时退回上次枚举的结果(可能稍旧,但只影响"会不会去问它",不影响是否放行)。
|
|
22367
|
+
*
|
|
22368
|
+
* ⚠️ 这里是**同步**的(upgrade 回调用)⇒ 不 await、不扫盘。
|
|
22369
|
+
*/
|
|
22370
|
+
function knownAcpRoots() {
|
|
22371
|
+
return listAcpRoots(knownRoots);
|
|
22372
|
+
}
|
|
22373
|
+
/**
|
|
22374
|
+
* 刷新"本机有哪些 CCC"(async,枚举宿主三来源)。
|
|
22375
|
+
*
|
|
22376
|
+
* 🔴 **失败必须响亮**:v1.59.0 这里是 `void refresh(...)` 且没有 catch ——
|
|
22377
|
+
* 枚举失败会让名单清空 ⇒ **所有连接一律 401**,而现场只有一行 stdout 看不到的日志。
|
|
22378
|
+
* 这正是本轨记过的「调用没报错 ≠ 已验证」。
|
|
22379
|
+
*
|
|
22380
|
+
* @returns 本次枚举到的根数(0 = 一个都没发现,日志会说为什么)
|
|
22047
22381
|
*/
|
|
22048
|
-
let acpRoots = [];
|
|
22049
|
-
/** 刷新准入名单(**只收启用了 ACP 通道且已生成 token 的 CCC**)。返回新名单长度。 */
|
|
22050
22382
|
async function refreshAcpRoots(ctx) {
|
|
22051
22383
|
try {
|
|
22052
|
-
|
|
22053
|
-
|
|
22054
|
-
|
|
22055
|
-
|
|
22056
|
-
console.log(`[serenity-hooks] ✓ 面 E:已为 CCC 生成 ACP token(${root},指纹 ${tokenFingerprint(token)})`);
|
|
22057
|
-
}
|
|
22384
|
+
knownRoots = (await listCccs(ctx)).map((e) => e.root);
|
|
22385
|
+
let created = 0;
|
|
22386
|
+
for (const root of knownRoots) try {
|
|
22387
|
+
created += ensureAcpChannelTokens(root).length;
|
|
22058
22388
|
} catch (err) {
|
|
22059
|
-
console.warn(`[serenity-hooks] 面 E:为 CCC
|
|
22389
|
+
console.warn(`[serenity-hooks] 面 E:为 CCC(${root})生成 token 失败: ${String(err?.message ?? err)}`);
|
|
22060
22390
|
}
|
|
22061
|
-
|
|
22391
|
+
if (created > 0) console.log(`[serenity-hooks] ✓ 面 E:已为 ${created} 个 ACP 账户生成 token`);
|
|
22392
|
+
return knownRoots.length;
|
|
22062
22393
|
} catch (err) {
|
|
22063
|
-
|
|
22064
|
-
|
|
22394
|
+
knownRoots = [];
|
|
22395
|
+
const msg = `面 E:CCC 枚举失败(准入名单将为空,所有连接会被拒): ${String(err?.message ?? err)}`;
|
|
22396
|
+
console.warn(`[serenity-hooks] ${msg}`);
|
|
22397
|
+
state.lastErrors.push({
|
|
22398
|
+
at: Date.now(),
|
|
22399
|
+
applied: false,
|
|
22400
|
+
error: msg
|
|
22401
|
+
});
|
|
22402
|
+
if (state.lastErrors.length > MAX_ERR_KEEP) state.lastErrors.shift();
|
|
22403
|
+
return 0;
|
|
22065
22404
|
}
|
|
22066
|
-
return acpRoots.length;
|
|
22067
22405
|
}
|
|
22068
22406
|
/**
|
|
22069
22407
|
* 把**本连接归属的 CCC 角色**套到该连接创建的 agent 上(v1.59.0)。
|
|
@@ -22162,13 +22500,51 @@ function acpFaceEnabled() {
|
|
|
22162
22500
|
return readSimpleSettings().acpFaceEnabled;
|
|
22163
22501
|
}
|
|
22164
22502
|
/**
|
|
22503
|
+
* 把客户端给的 `cwd` 归一化成宿主要求的**绝对路径**(v1.59.2)。
|
|
22504
|
+
*
|
|
22505
|
+
* ## 为什么需要
|
|
22506
|
+
* 宿主 `dsh-acp` 在每次 `session/new` 都有这一行(`lib/index.js` 逐字):
|
|
22507
|
+
* ```js
|
|
22508
|
+
* if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
|
|
22509
|
+
* ```
|
|
22510
|
+
* 而**连接归属哪个 CCC 已由 token 决定**(服务端权威)⇒ 让用户再手打一遍绝对路径
|
|
22511
|
+
* 是**同一个信息说两遍**,且那第二遍正是最容易打错的一遍。
|
|
22512
|
+
*
|
|
22513
|
+
* ## 规则(唯一一条)
|
|
22514
|
+
* **非绝对路径 ⇒ 一律解释为「本连接归属 CCC 的根」**。
|
|
22515
|
+
*
|
|
22516
|
+
* | 客户端填 | 送宿主 |
|
|
22517
|
+
* |---|---|
|
|
22518
|
+
* | `serenity` / `a` / `.` / 空 | `/…/home-serenity`(该 token 的 CCC 根) |
|
|
22519
|
+
* | `/…/home-serenity`(已绝对且在根内) | 原样 |
|
|
22520
|
+
* | `/…/home-serenity/sub` | 原样(仍在根内) |
|
|
22521
|
+
* | `/etc`(绝对但在根外) | **原样透传,交给 `cwdInsideRoot` 拒** |
|
|
22522
|
+
*
|
|
22523
|
+
* 🔴 **最后一行为什么不在这里直接拒**:越权判据必须**只有一处**(`cwdInsideRoot`,
|
|
22524
|
+
* 它也管着角色认领),在这里再判一次就是**两份会漂的实现**。归一化只负责"把非绝对变成绝对"。
|
|
22525
|
+
*
|
|
22526
|
+
* @param raw 客户端原样给的 `cwd`(可能是 `undefined`)
|
|
22527
|
+
* @param cccRoot 本连接归属的 CCC 根
|
|
22528
|
+
*/
|
|
22529
|
+
function normalizeClientCwd(raw, cccRoot) {
|
|
22530
|
+
const value = typeof raw === "string" ? raw.trim() : "";
|
|
22531
|
+
if (value === "" || !isAbsolute(value)) return cccRoot;
|
|
22532
|
+
return value;
|
|
22533
|
+
}
|
|
22534
|
+
/**
|
|
22165
22535
|
* 把一条裸 WS socket 适配成 ACP `Stream`。
|
|
22166
22536
|
*
|
|
22167
22537
|
* 🔴 与官方 `ndJsonStream` **同语义**:我们自己做「消息 ↔ 文本帧」的分帧(按行)。
|
|
22168
22538
|
* 实测注意(P0′):`dsh-acp` 写出的每条消息**自带换行**,客户端同样 ⇒ 按行切分即正确。
|
|
22169
22539
|
* ⚠️ **空行要丢**;**非 JSON 帧要容忍**(桥/客户端可能插入控制帧)。
|
|
22540
|
+
*
|
|
22541
|
+
* 🔴 v1.59.2:**入站消息在这里做一次 `cwd` 归一化**(见 {@link normalizeClientCwd})。
|
|
22542
|
+
* 为什么是这里:`session/new` 与 `session/resume` 都带 `cwd`,而这是**唯一**能同时
|
|
22543
|
+
* 拦到两者、且属于我们自己的缝(再往下就进 `dsh-acp` 了,那里改了就是改宿主)。
|
|
22544
|
+
*
|
|
22545
|
+
* @param cccRoot 本连接归属的 CCC 根(token 反查所得)
|
|
22170
22546
|
*/
|
|
22171
|
-
function wsToAcpStream(ws, onWarn) {
|
|
22547
|
+
function wsToAcpStream(ws, onWarn, cccRoot) {
|
|
22172
22548
|
return {
|
|
22173
22549
|
readable: new ReadableStream({ start(controller) {
|
|
22174
22550
|
let buf = "";
|
|
@@ -22179,11 +22555,14 @@ function wsToAcpStream(ws, onWarn) {
|
|
|
22179
22555
|
const line = buf.slice(0, i);
|
|
22180
22556
|
buf = buf.slice(i + 1);
|
|
22181
22557
|
if (!line.trim()) continue;
|
|
22558
|
+
let msg;
|
|
22182
22559
|
try {
|
|
22183
|
-
|
|
22560
|
+
msg = JSON.parse(line);
|
|
22184
22561
|
} catch {
|
|
22185
22562
|
onWarn(line.slice(0, 120));
|
|
22563
|
+
continue;
|
|
22186
22564
|
}
|
|
22565
|
+
controller.enqueue(normalizeInboundCwd(msg, cccRoot));
|
|
22187
22566
|
}
|
|
22188
22567
|
});
|
|
22189
22568
|
ws.on("close", () => {
|
|
@@ -22202,6 +22581,26 @@ function wsToAcpStream(ws, onWarn) {
|
|
|
22202
22581
|
} })
|
|
22203
22582
|
};
|
|
22204
22583
|
}
|
|
22584
|
+
/**
|
|
22585
|
+
* 若这条消息带 `params.cwd`,就地归一化(**只动 `session/new` / `session/resume`**)。
|
|
22586
|
+
*
|
|
22587
|
+
* 🔴 判据 = **有 `params.cwd` 就归一化**,不按 method 白名单挑:
|
|
22588
|
+
* 带 `cwd` 的方法只有那两个,而白名单一旦漏项就会**静默退回旧行为**(最难查的一类)。
|
|
22589
|
+
*/
|
|
22590
|
+
function normalizeInboundCwd(msg, cccRoot) {
|
|
22591
|
+
if (typeof msg !== "object" || msg === null) return msg;
|
|
22592
|
+
const m = msg;
|
|
22593
|
+
if (typeof m.params !== "object" || m.params === null) return msg;
|
|
22594
|
+
if (!("cwd" in m.params)) return msg;
|
|
22595
|
+
const normalized = normalizeClientCwd(m.params.cwd, cccRoot);
|
|
22596
|
+
return {
|
|
22597
|
+
...msg,
|
|
22598
|
+
params: {
|
|
22599
|
+
...m.params,
|
|
22600
|
+
cwd: normalized
|
|
22601
|
+
}
|
|
22602
|
+
};
|
|
22603
|
+
}
|
|
22205
22604
|
function newState() {
|
|
22206
22605
|
return {
|
|
22207
22606
|
handle: null,
|
|
@@ -22284,7 +22683,7 @@ ACP 面:认证失败(${verdict.reason})。请在该 CCC 的 localstore 生
|
|
|
22284
22683
|
const connRole = verdict.role;
|
|
22285
22684
|
const stream = wsToAcpStream(ws, (line) => {
|
|
22286
22685
|
console.log(`[serenity-hooks] 面 E:丢弃一条非 JSON 帧(前 120 字): ${line}`);
|
|
22287
|
-
});
|
|
22686
|
+
}, connRoot);
|
|
22288
22687
|
const fiber = ctx.plugin({
|
|
22289
22688
|
inject: ACP_REQUIRED_SERVICES,
|
|
22290
22689
|
apply: (scoped) => {
|
|
@@ -22451,7 +22850,7 @@ function registerAcpFace(ctx) {
|
|
|
22451
22850
|
} catch {}
|
|
22452
22851
|
const refresh = () => {
|
|
22453
22852
|
refreshAcpRoots(ctx).then((n) => {
|
|
22454
|
-
console.log(`[serenity-hooks] 面 E:CCC
|
|
22853
|
+
console.log(`[serenity-hooks] 面 E:CCC 候选根已枚举(${n} 个)`);
|
|
22455
22854
|
});
|
|
22456
22855
|
};
|
|
22457
22856
|
try {
|