@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/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 { _ as runLocalStore, a as readWeixinCredential, c as weixinInboundDir, d as LOCALSTORE_SCOPES, f as checkLocalstoreGitCompliance, g as readStore, h as readGitTrack, i as matchWeixinRoute, l as weixinSessionIdFor, m as localstorePath, n as extractWeixinText, o as readWeixinSettings, p as getEntry, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia, v as writeEntry } from "./weixin-route-CSDBGLwl.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-CSDBGLwl.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-CSDBGLwl.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-CSDBGLwl.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-CSDBGLwl.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-CSDBGLwl.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-CSDBGLwl.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-CSDBGLwl.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-CSDBGLwl.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";
@@ -21853,19 +21950,56 @@ function registerWeixinSendApi(ctx) {
21853
21950
  *
21854
21951
  * 本模块**零 DSH 依赖**(只 `node:crypto` / `node:fs` / `node:path` 与本仓纯逻辑)⇒ 可独立单测。
21855
21952
  */
21856
- /** localstore 凭据键(credential scope ⇒ UPPER_SNAKE) */
21857
- const ACP_TOKEN_KEY = "IM_ACP_TOKEN";
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
- * 该 CCC 的 ACP token(**读**;不存在返回 null)。
21993
+ * 该**账户**的 ACP token(**读**;不存在返回 null)。
21994
+ *
21995
+ * 🔴 **不自动生成** —— 生成是**写操作**,只在显式要求时做(`ensureAcpToken`)。
21996
+ * 读路径永远无副作用:否则每次判定都会改 localstore。
21862
21997
  *
21863
- * 🔴 **不自动生成** —— 生成是**写操作**,只在 owner/面板显式要求时做
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", ACP_TOKEN_KEY);
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", ACP_TOKEN_KEY, token);
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
- * 校验一枚候选 token,返回它归属的 CCC 根(**未命中返回 null**)。
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 resolveTokenOwner(candidate, roots) {
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
- * 枚举**启用了 ACP 通道**的 CCC(供 `resolveTokenOwner` 用)。
22148
+ * 枚举**有可用 ACP 账户**的 CCC(供准入名单用)。
21940
22149
  *
21941
- * 判据(与装配层同源,见 `acpChannelEnabled`):`im.channel === 'acp'` ∧ `im.acp.enabled === true`
21942
- * ∧ token 已生成。⚠️ 三者**缺一即不算**(fail-closed:宁可连不上,不可放行一个半配置的通道)。
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 = acpRoots;
22332
+ const roots = knownAcpRoots();
22020
22333
  if (roots.length === 0) return {
22021
22334
  ok: false,
22022
22335
  reason: "本机没有任何启用了 ACP 通道的 CCC"
22023
22336
  };
22024
- const owner = resolveTokenOwner(candidate, roots);
22025
- if (!owner) return {
22337
+ const acct = resolveTokenAccount(candidate, roots);
22338
+ if (!acct) return {
22026
22339
  ok: false,
22027
- reason: "token 不匹配任何已启用 ACP 通道的 CCC"
22340
+ reason: "token 不匹配任何已声明账户的 ACP 通道"
22028
22341
  };
22029
- const role = acpRoleFor(owner);
22342
+ const role = acct.role;
22030
22343
  if (!role) return {
22031
22344
  ok: false,
22032
- reason: `CCC(${owner})未配置 im.acp.role`
22345
+ reason: `CCC(${acct.root})账户 ${acct.accountId} 未配 im.acp.accounts[].role`
22033
22346
  };
22034
22347
  return {
22035
22348
  ok: true,
22036
- root: owner,
22037
- role
22349
+ root: acct.root,
22350
+ role,
22351
+ accountId: acct.accountId
22038
22352
  };
22039
22353
  }
22040
22354
  /**
22041
- * 归属候选 CCC 的**同步**缓存。
22355
+ * CCC 根候选的来源(**只要根,不判配置**)。
22042
22356
  *
22043
- * 🔴 为什么缓存而非每次现算:`listCccs()` 是 **async**(要读 workspace registry +
22044
- * `sessionPersistence`),而 `'upgrade'` 回调是**同步**的 ⇒ 不能在那里 await。
22045
- * ⇒ 启动时算一次 + 配置变更时刷新;这是**准入名单**,宁可用稍旧的名单(新 CCC 多等一次
22046
- * 配置事件即可生效),也不让 upgrade 挂起。
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
- const all = (await listCccs(ctx)).map((e) => e.root);
22053
- for (const root of all) try {
22054
- if (acpChannelEnabled(root) && !readAcpToken(root)) {
22055
- const token = ensureAcpToken(root);
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 生成 token 失败(${root}): ${String(err?.message ?? err)}`);
22389
+ console.warn(`[serenity-hooks] 面 E:为 CCC(${root})生成 token 失败: ${String(err?.message ?? err)}`);
22060
22390
  }
22061
- acpRoots = listAcpRoots(all);
22391
+ if (created > 0) console.log(`[serenity-hooks] ✓ 面 E:已为 ${created} 个 ACP 账户生成 token`);
22392
+ return knownRoots.length;
22062
22393
  } catch (err) {
22063
- acpRoots = [];
22064
- console.warn(`[serenity-hooks] 面 E:CCC 准入名单刷新失败(已清空,连接将被拒): ${String(err?.message ?? 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;
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
- controller.enqueue(JSON.parse(line));
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 准入名单已刷新(${n} 个 CCC 启用 ACP 通道)`);
22853
+ console.log(`[serenity-hooks] 面 E:CCC 候选根已枚举(${n} 个)`);
22455
22854
  });
22456
22855
  };
22457
22856
  try {