@shgroup/dsh-serenity-hooks 1.58.2 → 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/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 { 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-B9gU1YXz.js";
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-B9gU1YXz.js").then((n) => n.u);
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-B9gU1YXz.js").then((n) => n.u);
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-B9gU1YXz.js").then((n) => n.u);
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-B9gU1YXz.js").then((n) => n.u);
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-B9gU1YXz.js").then((n) => n.u);
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-B9gU1YXz.js").then((n) => n.u);
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-B9gU1YXz.js").then((n) => n.u);
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-B9gU1YXz.js").then((n) => n.u);
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";
@@ -21816,9 +21913,566 @@ function registerWeixinSendApi(ctx) {
21816
21913
  sync();
21817
21914
  }
21818
21915
  //#endregion
21916
+ //#region src/acp-auth.ts
21917
+ /**
21918
+ * acp-auth.ts — ACP 面的**认证 + 身份**层(v1.59.0,owner 2026-10-17 令)
21919
+ *
21920
+ * ## 它解决什么
21921
+ *
21922
+ * owner 令(逐字):「将微信桥升级为 IM 桥,每个 CCC 可以选择微信实现或 ACP 实现,
21923
+ * 微信实现保留不变向前兼容,**ACP 实现就可以暴露端口并绑定 skiff,并且要随即生成认证用的 token**」。
21924
+ *
21925
+ * 面 E(`acp-face.ts`)原本**无认证、无 CCC 归属、无角色** —— 本模块补上这三件:
21926
+ * ① **token**:每 CCC 一枚,落**该 CCC 的 localstore**(credential scope)。
21927
+ * ② **CCC 归属**:连接建立时由 token 反查 ⇒ **服务端权威**(不拿客户端自述的 `cwd` 当身份)。
21928
+ * ③ **角色**:该 CCC 的 `im.acp.role` ⇒ 供装配层把 ACP 会话套上 skiff 角色。
21929
+ *
21930
+ * ## 🔴 架构决策:一面多 token,不是每 CCC 一个端口
21931
+ *
21932
+ * | | 甲:每 CCC 一个端口 | **乙:一面多 token** ← 选它 |
21933
+ * |---|---|---|
21934
+ * | 端口 | 随 CCC 数增长 | **固定 3200** |
21935
+ * | 身份来源 | 端口号 | **token(一一对应 CCC)** |
21936
+ *
21937
+ * **判据**:`token → CCC` 是一一对应,而 `cwd` 是**客户端可填的**。
21938
+ * ⇒ 用 token 定 CCC 比用 cwd 定 CCC 强:前者**服务端权威**,后者**客户端自述**。
21939
+ * ⇒ 连接一建立就知道"这是哪个 CCC",**该连接的所有会话都归它**,角色绑定无需猜。
21940
+ * ⚠️ `cwd` 仍要校验(见 `cwdInsideRoot`):防一枚 token 越权到别的 CCC。
21941
+ *
21942
+ * ## 🔴 为什么认证放在 WS upgrade 层,而不在 ACP 协议里
21943
+ *
21944
+ * `dsh-acp` 的 `authenticate()` **恒成功**且 `authMethods: []`(读其 `lib/index.js` 逐字),
21945
+ * 且它**不导出**可替换的认证缝 ⇒ 改协议层要重写它的接线(P0′ 已实测此路不通)。
21946
+ * 而**我们自己持有 HTTP server**(自持面)⇒ 在 `upgrade` **升级之前**校验,
21947
+ * 失败即 `401 + destroy` ⇒ **连接根本进不来**。
21948
+ *
21949
+ * ## 边界
21950
+ *
21951
+ * 本模块**零 DSH 依赖**(只 `node:crypto` / `node:fs` / `node:path` 与本仓纯逻辑)⇒ 可独立单测。
21952
+ */
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
+ }
21990
+ /** token 字节数(32 B = 256 bit;hex 后 64 字符) */
21991
+ const TOKEN_BYTES = 32;
21992
+ /**
21993
+ * 该**账户**的 ACP token(**读**;不存在返回 null)。
21994
+ *
21995
+ * 🔴 **不自动生成** —— 生成是**写操作**,只在显式要求时做(`ensureAcpToken`)。
21996
+ * 读路径永远无副作用:否则每次判定都会改 localstore。
21997
+ *
21998
+ * @param accountId 账户键(必填 —— 凭据按账户分离,没有"CCC 默认 token"这回事)
21999
+ */
22000
+ function readAcpToken(root, accountId) {
22001
+ try {
22002
+ const value = getEntry(root, "credential", acpTokenKeyFor(accountId));
22003
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
22004
+ } catch {
22005
+ return null;
22006
+ }
22007
+ }
22008
+ /**
22009
+ * 生成(或返回既有)ACP token 并落该 CCC 的 localstore。
22010
+ *
22011
+ * ⚠️ **幂等**:已有 token 时**原样返回**(不轮换)—— 轮换会让在用的客户端立刻断线,
22012
+ * 必须是一次显式动作(`resetAcpToken`)。
22013
+ *
22014
+ * @param accountId 多账户时指明哪个账户;省略 = 单账户简写键
22015
+ */
22016
+ function ensureAcpToken(root, accountId) {
22017
+ const existing = readAcpToken(root, accountId);
22018
+ if (existing) return existing;
22019
+ const token = randomBytes(TOKEN_BYTES).toString("hex");
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);
22030
+ return token;
22031
+ }
22032
+ /**
22033
+ * 常时比较(防时序侧信道)。
22034
+ *
22035
+ * 🔴 两个长度不等时 `timingSafeEqual` **会抛** ⇒ 先比长度并立刻返回 false。
22036
+ * ⚠️ 那一步本身**泄漏长度**(可接受:token 长度固定且公开)。
22037
+ */
22038
+ function safeEqual(a, b) {
22039
+ const bufA = Buffer.from(a, "utf8");
22040
+ const bufB = Buffer.from(b, "utf8");
22041
+ if (bufA.length !== bufB.length) return false;
22042
+ return timingSafeEqual(bufA, bufB);
22043
+ }
22044
+ /** token 指纹(诊断面用;不可逆) */
22045
+ function tokenFingerprint(token) {
22046
+ return createHash("sha256").update(token).digest("hex").slice(0, 12);
22047
+ }
22048
+ /**
22049
+ * 从一次 HTTP upgrade 请求里取出候选 token。
22050
+ *
22051
+ * 两种携带方式都认(客户端差异;Agmente 等用具名头,脚本常用查询串):
22052
+ * ① `Authorization: Bearer <t>`
22053
+ * ② `?token=<t>`(查询串)
22054
+ *
22055
+ * @returns 取到的 token;两者都无 → null
22056
+ */
22057
+ function extractToken(headers, url) {
22058
+ const auth = headers["authorization"];
22059
+ if (typeof auth === "string") {
22060
+ const m = /^Bearer\s+(.+)$/i.exec(auth.trim());
22061
+ if (m?.[1]) return m[1].trim();
22062
+ }
22063
+ if (typeof url === "string" && url !== "") try {
22064
+ const q = new URL(url, "http://placeholder.invalid").searchParams.get("token");
22065
+ if (q && q.trim() !== "") return q.trim();
22066
+ } catch {}
22067
+ return null;
22068
+ }
22069
+ /**
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)。
22138
+ *
22139
+ * @param candidate 客户端出示的 token
22140
+ * @param roots 候选 CCC 根集合(由调用方枚举——本模块不认识"CCC 在哪")
22141
+ */
22142
+ function resolveTokenAccount(candidate, roots, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
22143
+ if (!candidate) return null;
22144
+ for (const root of roots) for (const acct of expandAcpAccounts(root, paths)) if (safeEqual(candidate, acct.token)) return acct;
22145
+ return null;
22146
+ }
22147
+ /**
22148
+ * 枚举**有可用 ACP 账户**的 CCC(供准入名单用)。
22149
+ *
22150
+ * 判据:`im.channel === 'acp'` ∧ `im.acp.enabled === true` ∧ **至少一个账户有 token 且配了角色**。
22151
+ * ⚠️ 缺一即不算(fail-closed:宁可连不上,不可放行一个半配置的通道)。
22152
+ */
22153
+ function listAcpRoots(roots, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
22154
+ const out = [];
22155
+ for (const root of roots) if (expandAcpAccounts(root, paths).length > 0) out.push(root);
22156
+ return out;
22157
+ }
22158
+ /** 读该 CCC 的 `im` 段(未配置 ⇒ 归一缺省;永不 undefined) */
22159
+ function readImSettings(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
22160
+ try {
22161
+ const im = loadSerenityConfig(root, paths).im;
22162
+ if (!im || typeof im !== "object") return {
22163
+ channel: "weixin",
22164
+ acp: {}
22165
+ };
22166
+ return {
22167
+ channel: im.channel === "acp" ? "acp" : "weixin",
22168
+ acp: im.acp && typeof im.acp === "object" ? im.acp : {}
22169
+ };
22170
+ } catch {
22171
+ return {
22172
+ channel: "weixin",
22173
+ acp: {}
22174
+ };
22175
+ }
22176
+ }
22177
+ /**
22178
+ * 该 CCC 是否启用 ACP 入站通道。
22179
+ *
22180
+ * 🔴 **两个条件都真才算**:`im.channel === 'acp'` **且** `im.acp.enabled === true`。
22181
+ * 为什么双键:`channel` 表达"选了哪条路",`enabled` 表达"现在开不开" ⇒
22182
+ * 让 owner 能**选好 ACP 但暂时关着**(不用删配置),与既有各面的 `enabled` 范式一致。
22183
+ */
22184
+ function acpChannelEnabled(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
22185
+ const im = readImSettings(root, paths);
22186
+ return im.channel === "acp" && im.acp.enabled === true;
22187
+ }
22188
+ /**
22189
+ * 客户端 `session/new` 传来的 `cwd` 是否落在该 token 归属的 CCC 内。
22190
+ *
22191
+ * 🔴 **为什么必须校验**:token 定的是"哪个 CCC",而 `cwd` 由**客户端**指定。
22192
+ * 不校验 ⇒ 一枚 CCC A 的 token 可以把会话开到 CCC B(越权读到另一个 CCC 的上下文)。
22193
+ * 这与 CCC 自身的路径围墙(P3)是同一条纪律,只是发生在**接入**这一层。
22194
+ *
22195
+ * 判据用 `relative()` 而非 `startsWith`(后者会把 `/a/bc` 误判为在 `/a/b` 内)。
22196
+ */
22197
+ function cwdInsideRoot(cwd, root) {
22198
+ if (!isAbsolute(cwd)) return false;
22199
+ const rel = relative(resolve(root), resolve(cwd));
22200
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
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
+ }
22315
+ //#endregion
21819
22316
  //#region src/acp-face.ts
21820
22317
  /** 面名(面宿主幂等键;与 FACE_PORTS 的 id 同名,便于对账) */
21821
22318
  const FACE_ACP = "acp";
22319
+ /**
22320
+ * 校验一次 upgrade 请求(**只读**:不改任何状态)。
22321
+ *
22322
+ * 判据链(任一环失败即拒,fail-closed):候选 token 缺失 ⇒ 不匹配任何 CCC ⇒
22323
+ * 该 CCC 未启用 ACP 通道 ⇒ 该 CCC 未配角色。最后一环尤其重要:**没有角色的 ACP 会话
22324
+ * 等于一个无身份、无约束的裸 agent** ⇒ 宁可连不上。
22325
+ */
22326
+ function authorizeUpgrade(req) {
22327
+ const candidate = extractToken(req.headers, req.url);
22328
+ if (!candidate) return {
22329
+ ok: false,
22330
+ reason: "未携带 token(用 Authorization: Bearer <t> 或 ?token=<t>)"
22331
+ };
22332
+ const roots = knownAcpRoots();
22333
+ if (roots.length === 0) return {
22334
+ ok: false,
22335
+ reason: "本机没有任何启用了 ACP 通道的 CCC"
22336
+ };
22337
+ const acct = resolveTokenAccount(candidate, roots);
22338
+ if (!acct) return {
22339
+ ok: false,
22340
+ reason: "token 不匹配任何已声明账户的 ACP 通道"
22341
+ };
22342
+ const role = acct.role;
22343
+ if (!role) return {
22344
+ ok: false,
22345
+ reason: `CCC(${acct.root})账户 ${acct.accountId} 未配 im.acp.accounts[].role`
22346
+ };
22347
+ return {
22348
+ ok: true,
22349
+ root: acct.root,
22350
+ role,
22351
+ accountId: acct.accountId
22352
+ };
22353
+ }
22354
+ /**
22355
+ * CCC 根候选的来源(**只要根,不判配置**)。
22356
+ *
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 = 一个都没发现,日志会说为什么)
22381
+ */
22382
+ async function refreshAcpRoots(ctx) {
22383
+ try {
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;
22388
+ } catch (err) {
22389
+ console.warn(`[serenity-hooks] 面 E:为 CCC(${root})生成 token 失败: ${String(err?.message ?? err)}`);
22390
+ }
22391
+ if (created > 0) console.log(`[serenity-hooks] ✓ 面 E:已为 ${created} 个 ACP 账户生成 token`);
22392
+ return knownRoots.length;
22393
+ } catch (err) {
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;
22404
+ }
22405
+ }
22406
+ /**
22407
+ * 把**本连接归属的 CCC 角色**套到该连接创建的 agent 上(v1.59.0)。
22408
+ *
22409
+ * ## 🔴 为什么是"认领"而不是"直接挂"
22410
+ *
22411
+ * 理想形态是:在本连接的 scoped fiber 里监听 `session/created`,**只**收到本连接建的会话。
22412
+ * 但那条路依赖一个**我没有实测过的**引擎行为 —— cordis 的 `EventsService.dispatch()` 只在
22413
+ * **派发方设了 `Context.filter`** 时才按 ctx 过滤(读 `@deepseek-ai/cordis/src/events.ts`
22414
+ * 的 `.filter(hook => hook.global || !filter || filter.call(thisArg, hook.ctx))`)。
22415
+ * ⇒ 若宿主派发 `session/created` 时**没设**过滤器,本监听器会**收到全进程**的会话
22416
+ * (网页端、别的轨迹、别的连接),**套错角色**。
22417
+ *
22418
+ * 🔴 **D119 纪律**:不许照源码的"意图"断言引擎行为 ⇒ 本实现**不赌**那条语义,
22419
+ * 改用**认领制**(两条过滤都可以自证,不依赖 filter):
22420
+ * ① **cwd 必须落在本连接归属的 CCC 内**(token 已定 CCC ⇒ 越界会话直接跳过);
22421
+ * ② **同一会话只被认领一次**(`claimed` 集合;先到先得 ⇒ 不会被另一条连接的监听器抢走)。
22422
+ *
22423
+ * ⚠️ **残余风险(如实标注)**:若**同一 CCC** 同时有网页端会话与 ACP 会话在创建,
22424
+ * 本函数可能把网页端那个会话也认领了(两者 cwd 都在该 CCC 内)。⇒ 判据是"cwd + 先到先得",
22425
+ * **不是"确属本连接"**。⚠️ **这一格必须由探针实测**(见 REBUILD-TODO 的待办);
22426
+ * 未实测前**不得**声称"只套本连接的会话"。
22427
+ *
22428
+ * 失败语义:全程不抛(本面是可选面)——角色挂不上只记日志,不阻断 ACP 接线。
22429
+ */
22430
+ function bindRoleToConnScopedAgent(scoped, root, roleName) {
22431
+ try {
22432
+ const role = readSkiffRoles(root).get(roleName);
22433
+ if (!role) {
22434
+ console.warn(`[serenity-hooks] 面 E:CCC(${root})未定义 skiff 角色 "${roleName}" ⇒ 本连接不注入角色`);
22435
+ return;
22436
+ }
22437
+ const rolePrompt = createRolePromptReader(root, role);
22438
+ const claimed = /* @__PURE__ */ new Set();
22439
+ const onCreated = (session) => {
22440
+ try {
22441
+ const id = session?.header?.id;
22442
+ const cwd = session?.header?.cwd;
22443
+ if (!id || !cwd) return;
22444
+ if (claimed.has(id)) return;
22445
+ if (!cwdInsideRoot(cwd, root)) return;
22446
+ const target = resolveAgentById(scoped, id);
22447
+ if (!target) return;
22448
+ target.ctx.systemPrompt.section({
22449
+ name: "serenity-skiff",
22450
+ order: -60,
22451
+ text: () => [buildSkiffBasePrompt(roleName, role), rolePrompt()].filter(Boolean).join("\n")
22452
+ });
22453
+ claimed.add(id);
22454
+ console.log(`[serenity-hooks] ✓ 面 E:会话 ${id} 已绑定角色 "${roleName}"(CCC=${root})`);
22455
+ } catch (err) {
22456
+ console.warn(`[serenity-hooks] 面 E:角色注入失败(不影响会话): ${String(err?.message ?? err)}`);
22457
+ }
22458
+ };
22459
+ for (const eventName of ["session/created", "agent/created"]) try {
22460
+ scoped.on(eventName, onCreated);
22461
+ } catch {}
22462
+ } catch (err) {
22463
+ console.warn(`[serenity-hooks] 面 E:角色绑定装配失败: ${String(err?.message ?? err)}`);
22464
+ }
22465
+ }
22466
+ /** 按会话 id 取 live agent(只读;取不到返回 undefined) */
22467
+ function resolveAgentById(scoped, sessionId) {
22468
+ try {
22469
+ const agent = hostInjected(scoped, "agents")?.get?.(sessionId);
22470
+ if (!agent?.ctx?.systemPrompt || typeof agent.ctx.systemPrompt.section !== "function") return void 0;
22471
+ return agent;
22472
+ } catch {
22473
+ return;
22474
+ }
22475
+ }
21822
22476
  /** 本面绑定的地址(取自集中端口表 —— **单一真相源**,不在本文件硬写) */
21823
22477
  const ACP_HOST = FACE_PORTS.acp.host ?? "127.0.0.1";
21824
22478
  /**
@@ -21846,13 +22500,51 @@ function acpFaceEnabled() {
21846
22500
  return readSimpleSettings().acpFaceEnabled;
21847
22501
  }
21848
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
+ /**
21849
22535
  * 把一条裸 WS socket 适配成 ACP `Stream`。
21850
22536
  *
21851
22537
  * 🔴 与官方 `ndJsonStream` **同语义**:我们自己做「消息 ↔ 文本帧」的分帧(按行)。
21852
22538
  * 实测注意(P0′):`dsh-acp` 写出的每条消息**自带换行**,客户端同样 ⇒ 按行切分即正确。
21853
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 反查所得)
21854
22546
  */
21855
- function wsToAcpStream(ws, onWarn) {
22547
+ function wsToAcpStream(ws, onWarn, cccRoot) {
21856
22548
  return {
21857
22549
  readable: new ReadableStream({ start(controller) {
21858
22550
  let buf = "";
@@ -21863,11 +22555,14 @@ function wsToAcpStream(ws, onWarn) {
21863
22555
  const line = buf.slice(0, i);
21864
22556
  buf = buf.slice(i + 1);
21865
22557
  if (!line.trim()) continue;
22558
+ let msg;
21866
22559
  try {
21867
- controller.enqueue(JSON.parse(line));
22560
+ msg = JSON.parse(line);
21868
22561
  } catch {
21869
22562
  onWarn(line.slice(0, 120));
22563
+ continue;
21870
22564
  }
22565
+ controller.enqueue(normalizeInboundCwd(msg, cccRoot));
21871
22566
  }
21872
22567
  });
21873
22568
  ws.on("close", () => {
@@ -21886,12 +22581,33 @@ function wsToAcpStream(ws, onWarn) {
21886
22581
  } })
21887
22582
  };
21888
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
+ }
21889
22604
  function newState() {
21890
22605
  return {
21891
22606
  handle: null,
21892
22607
  wss: null,
21893
22608
  connections: 0,
21894
22609
  applied: 0,
22610
+ rejected: 0,
21895
22611
  lastErrors: [],
21896
22612
  disposers: []
21897
22613
  };
@@ -21938,16 +22654,41 @@ async function startAcpFace(ctx) {
21938
22654
  local.handle = handle;
21939
22655
  handle.server.on("upgrade", (req, socket, head) => {
21940
22656
  local.connections += 1;
22657
+ const verdict = authorizeUpgrade(req);
22658
+ if (!verdict.ok) {
22659
+ local.rejected += 1;
22660
+ local.lastErrors.push({
22661
+ at: Date.now(),
22662
+ applied: false,
22663
+ error: `auth: ${verdict.reason}`
22664
+ });
22665
+ if (local.lastErrors.length > MAX_ERR_KEEP) local.lastErrors.shift();
22666
+ try {
22667
+ socket.write(`HTTP/1.1 401 Unauthorized\r
22668
+ content-type: text/plain; charset=utf-8\r
22669
+ connection: close\r
22670
+ \r
22671
+ ACP 面:认证失败(${verdict.reason})。请在该 CCC 的 localstore 生成并使用 IM_ACP_TOKEN。\n`);
22672
+ } catch {}
22673
+ try {
22674
+ socket.destroy();
22675
+ } catch {}
22676
+ console.warn(`[serenity-hooks] ✗ 面 E:拒绝一条连接(${verdict.reason})`);
22677
+ return;
22678
+ }
21941
22679
  try {
21942
22680
  wss.handleUpgrade(req, socket, head, (ws) => {
21943
22681
  try {
22682
+ const connRoot = verdict.root;
22683
+ const connRole = verdict.role;
21944
22684
  const stream = wsToAcpStream(ws, (line) => {
21945
22685
  console.log(`[serenity-hooks] 面 E:丢弃一条非 JSON 帧(前 120 字): ${line}`);
21946
- });
22686
+ }, connRoot);
21947
22687
  const fiber = ctx.plugin({
21948
22688
  inject: ACP_REQUIRED_SERVICES,
21949
22689
  apply: (scoped) => {
21950
22690
  const sel = resolveAcpSelection(scoped);
22691
+ bindRoleToConnScopedAgent(scoped, connRoot, connRole);
21951
22692
  dshAcp.apply(scoped, {
21952
22693
  stream,
21953
22694
  provider: sel.provider,
@@ -22107,6 +22848,17 @@ function registerAcpFace(ctx) {
22107
22848
  sync();
22108
22849
  });
22109
22850
  } catch {}
22851
+ const refresh = () => {
22852
+ refreshAcpRoots(ctx).then((n) => {
22853
+ console.log(`[serenity-hooks] 面 E:CCC 候选根已枚举(${n} 个)`);
22854
+ });
22855
+ };
22856
+ try {
22857
+ ctx.on("settings/document-updated", (ns) => {
22858
+ if (String(ns) === "serenity-hooks") refresh();
22859
+ });
22860
+ } catch {}
22861
+ refresh();
22110
22862
  sync();
22111
22863
  }
22112
22864
  //#endregion