@clawos-dev/clawd 0.2.253 → 0.2.255

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/dist/cli.cjs CHANGED
@@ -166,6 +166,10 @@ var init_methods = __esm({
166
166
  // owner UI 授权 contact 反向操作我的设备(跨设备 exec 开关 remoteAccessAllowed)。
167
167
  // 详见 protocol/src/contact-remote-access.ts.
168
168
  "contact:setRemoteAccess",
169
+ // ---- contact:handshake (双向确认建联握手, spec 2026-07-22) ----
170
+ // device:connect 临时连接调用;被调方同步落库+broadcast 后回 ok(携带身份载荷)。
171
+ // 替代旧 whoami+auto-reverse 组合;whoami 对 connect-token guest 已封(旧客户端拦截)。
172
+ "contact:handshake",
169
173
  // ---- visitor:* (web 访客 · persona web 分享,spec 2026-06-24-persona-web-share-design) ----
170
174
  // owner-only:列出登录访问过本机 public persona 的 web 访客(零安装、飞书登录、daemon 自签
171
175
  // visitor token)。存储 ~/.clawd/visitors.json(VisitorStore),登录(exchange)即 upsert;
@@ -5367,13 +5371,13 @@ var init_schemas = __esm({
5367
5371
  * guest 的设备 id / 人 id / 来源全部由 connect token 的 subDevice/subOwner/provider 签名背书
5368
5372
  * (server 签、目标设备本地验签,不可伪造),不再接受任何自报身份。
5369
5373
  *
5370
- * selfUrl 保留:它只是"可达地址"(auto-reverse 反向连接用),不是身份——填错只影响
5371
- * 反向连通性,不构成伪造风险,且 url 不在 token 里(token 只签身份不签地址)。
5374
+ * selfUrl 已废弃(spec 2026-07-22 双向确认建联):可达地址自报移入 contact:handshake
5375
+ * args——auth 层回归纯验票。旧客户端发来的 selfUrl strip(zod 默认),无害;
5376
+ * 旧客户端本身由 whoami 的 connect-token 拒绝路径拦截(CLIENT_OUTDATED)。
5372
5377
  */
5373
- selfUrl: external_exports.string().min(1).optional(),
5374
5378
  /**
5375
5379
  * 客户端自报的 user-agent 性质标签(诊断/展示用)。
5376
- * selfUrl 性质的「自报事实」,非身份(决策 #16 边界):不参与鉴权、不参与路由、
5380
+ * 「自报事实」,非身份(决策 #16 边界):不参与鉴权、不参与路由、
5377
5381
  * 任何 handler 不得据此做权限判断。name 约定命名('clawd-desktop' / 'clawd-web' /
5378
5382
  * 'clawd-mobile-android' / ...),宽容未知值(将来加新客户端不动 schema)。
5379
5383
  * instanceId:客户端首启生成 UUID 并本地持久化——区分同种类多实例(多手机/多 desktop 同连)。
@@ -5827,6 +5831,32 @@ var init_contact = __esm({
5827
5831
  }
5828
5832
  });
5829
5833
 
5834
+ // ../protocol/src/contact-handshake.ts
5835
+ var ContactHandshakeArgsSchema, ContactHandshakeOkSchema;
5836
+ var init_contact_handshake = __esm({
5837
+ "../protocol/src/contact-handshake.ts"() {
5838
+ "use strict";
5839
+ init_zod();
5840
+ init_capability();
5841
+ ContactHandshakeArgsSchema = external_exports.object({
5842
+ selfUrl: external_exports.string().min(1)
5843
+ });
5844
+ ContactHandshakeOkSchema = external_exports.object({
5845
+ type: external_exports.literal("contact:handshake:ok"),
5846
+ owner: external_exports.object({
5847
+ /** 被调方 deviceId(路由主键;调用方落 Contact 的主键用自己请求的 args.deviceId,此值供校验/展示) */
5848
+ id: external_exports.string(),
5849
+ /** 归属人(落 Contact.ownerId / provider,显示分组用) */
5850
+ ownerId: external_exports.string(),
5851
+ provider: external_exports.string(),
5852
+ displayName: external_exports.string()
5853
+ }),
5854
+ /** 被调方授调用方的 grants(落 Contact.grants) */
5855
+ grants: external_exports.array(GrantSchema)
5856
+ });
5857
+ }
5858
+ });
5859
+
5830
5860
  // ../protocol/src/contact-remote-access.ts
5831
5861
  var ContactSetRemoteAccessArgsSchema, ContactSetRemoteAccessOkSchema, ContactRemoteAccessUpdatedFrameSchema;
5832
5862
  var init_contact_remote_access = __esm({
@@ -6315,6 +6345,7 @@ var init_runtime = __esm({
6315
6345
  init_capability();
6316
6346
  init_inbox();
6317
6347
  init_contact();
6348
+ init_contact_handshake();
6318
6349
  init_contact_remote_access();
6319
6350
  init_peer_exec();
6320
6351
  init_extension();
@@ -45920,7 +45951,12 @@ var DEFAULT_PERSONAS = [
45920
45951
  sandboxProfile: DEFAULT_BYPASS_PROFILE
45921
45952
  },
45922
45953
  {
45923
- personaId: "persona-clawd-helper",
45954
+ // clawd 管家 (chief-of-staff):spec 2026-07-22-clawd-butler-persona
45955
+ // 新 personaId 独立于老 persona-clawd-helper(老 helper seed entry 已删;老用户机器上
45956
+ // 若有残留目录,是他们自留物,daemon 不再触碰)。bundle 里 CLAUDE.md 骨架 + 5 个
45957
+ // prebuild skill 走 copyBundleExtras 装到 `.claude/skills/`;skill fetch 用 daemon
45958
+ // 注入的 env CLAWOS_API 拼 `${CLAWOS_API}/api/docs/**` 拉线上手册。
45959
+ personaId: "persona-clawd-butler",
45924
45960
  label: "clawd \u7BA1\u5BB6",
45925
45961
  model: "opus",
45926
45962
  iconKey: "assist",
@@ -51053,10 +51089,7 @@ var AuthGate = class {
51053
51089
  st.pending = true;
51054
51090
  let r;
51055
51091
  try {
51056
- r = await this.opts.authenticate(
51057
- parsed.data.token,
51058
- parsed.data.selfUrl
51059
- );
51092
+ r = await this.opts.authenticate(parsed.data.token);
51060
51093
  } finally {
51061
51094
  st.pending = false;
51062
51095
  }
@@ -51160,7 +51193,8 @@ function constantTimeEqual2(a, b2) {
51160
51193
  function ownerContext(ownerPrincipalId, displayName, feishuUnionId) {
51161
51194
  return {
51162
51195
  principal: makeOwnerPrincipal(ownerPrincipalId, displayName, feishuUnionId),
51163
- grants: [{ resource: { type: "*" }, actions: ["admin"] }]
51196
+ grants: [{ resource: { type: "*" }, actions: ["admin"] }],
51197
+ tokenKind: "owner"
51164
51198
  };
51165
51199
  }
51166
51200
  function connectGuestContext(subDevice, subOwner, provider, displayName) {
@@ -51170,6 +51204,7 @@ function connectGuestContext(subDevice, subOwner, provider, displayName) {
51170
51204
  resource: { ...g2.resource },
51171
51205
  actions: [...g2.actions]
51172
51206
  })),
51207
+ tokenKind: "connect",
51173
51208
  ownerId: subOwner,
51174
51209
  provider
51175
51210
  };
@@ -51181,6 +51216,7 @@ function visitorGuestContext(p2) {
51181
51216
  resource: { ...g2.resource },
51182
51217
  actions: [...g2.actions]
51183
51218
  })),
51219
+ tokenKind: "visitor",
51184
51220
  ownerId: p2.visitorId,
51185
51221
  provider: p2.provider
51186
51222
  };
@@ -51529,6 +51565,7 @@ var ContactStore = class {
51529
51565
 
51530
51566
  // src/contact/connect-remote.ts
51531
51567
  var crypto5 = __toESM(require("crypto"), 1);
51568
+ init_protocol();
51532
51569
  var HANDSHAKE_TIMEOUT_MS = 5e3;
51533
51570
  var RPC_TIMEOUT_MS = 5e3;
51534
51571
  async function connectRemote(args) {
@@ -51541,17 +51578,7 @@ async function connectRemote(args) {
51541
51578
  });
51542
51579
  ws.once("error", onError);
51543
51580
  });
51544
- ws.send(
51545
- JSON.stringify({
51546
- type: "auth",
51547
- token: args.token,
51548
- // 自动反向(spec 决策 #11):带本机可达地址时对方反向建联系人;
51549
- // 缺省时省略字段(不发空串),对方 schema selfUrl 是 .min(1).optional()。
51550
- // 决策 #16:身份字段(selfPrincipalId/selfDisplayName/selfDeviceId)已删——
51551
- // 对方从 connect token 签名背书取 deviceId/ownerId/provider,不信自报。
51552
- ...args.selfUrl ? { selfUrl: args.selfUrl } : {}
51553
- })
51554
- );
51581
+ ws.send(JSON.stringify({ type: "auth", token: args.token }));
51555
51582
  await new Promise((resolve6, reject) => {
51556
51583
  const onMessage = (raw) => {
51557
51584
  let f;
@@ -51603,7 +51630,7 @@ async function connectRemote(args) {
51603
51630
  }
51604
51631
  if (f.requestId !== requestId) return;
51605
51632
  ws.off("message", onMessage);
51606
- if (f.error || f.type?.endsWith(":error")) {
51633
+ if (f.error || f.type === "error" || f.type?.endsWith(":error")) {
51607
51634
  reject(new Error(f.message ?? f.error ?? "rpc error"));
51608
51635
  } else {
51609
51636
  resolve6(f);
@@ -51618,13 +51645,14 @@ async function connectRemote(args) {
51618
51645
  });
51619
51646
  }
51620
51647
  return {
51621
- whoami: async () => {
51622
- const f = await call("whoami", {});
51648
+ handshake: async (selfUrl) => {
51649
+ const raw = await call("contact:handshake", { selfUrl });
51650
+ const f = ContactHandshakeOkSchema.parse(raw);
51623
51651
  return {
51624
51652
  ownerId: f.owner.ownerId,
51625
51653
  provider: f.owner.provider,
51626
51654
  displayName: f.owner.displayName,
51627
- grants: f.capability.grants
51655
+ grants: f.grants
51628
51656
  };
51629
51657
  },
51630
51658
  close: () => {
@@ -51636,43 +51664,6 @@ async function connectRemote(args) {
51636
51664
  };
51637
51665
  }
51638
51666
 
51639
- // src/contact/auto-reverse.ts
51640
- init_protocol();
51641
- async function autoReverseContact(args) {
51642
- if (!args.selfUrl) return;
51643
- const now = args.now ?? Date.now;
51644
- const grants = PERSONAL_CAP_GRANTS.map((g2) => ({
51645
- resource: { ...g2.resource },
51646
- actions: [...g2.actions]
51647
- }));
51648
- const existing = args.store.get(args.deviceId) ?? void 0;
51649
- const base = {
51650
- deviceId: args.deviceId,
51651
- ownerId: args.ownerId,
51652
- provider: args.provider,
51653
- displayName: args.displayName,
51654
- remoteUrl: args.selfUrl,
51655
- connectToken: "",
51656
- grants,
51657
- addedAt: now(),
51658
- pinnedAt: existing?.pinnedAt ?? null,
51659
- remoteAccessAllowed: existing?.remoteAccessAllowed ?? false
51660
- };
51661
- args.store.upsert(base);
51662
- args.broadcast({ type: "contact:added", contact: base });
51663
- if (!args.exchangeToken) return;
51664
- let token;
51665
- try {
51666
- token = await args.exchangeToken(args.deviceId);
51667
- } catch {
51668
- return;
51669
- }
51670
- if (!token) return;
51671
- const upgraded = { ...base, connectToken: token };
51672
- args.store.upsert(upgraded);
51673
- args.broadcast({ type: "contact:added", contact: upgraded });
51674
- }
51675
-
51676
51667
  // src/migrations/2026-05-20-flatten-sessions.ts
51677
51668
  var fs36 = __toESM(require("fs"), 1);
51678
51669
  var path37 = __toESM(require("path"), 1);
@@ -55278,6 +55269,45 @@ function buildInboxHandlers(deps) {
55278
55269
 
55279
55270
  // src/handlers/contact.ts
55280
55271
  init_protocol();
55272
+
55273
+ // src/contact/auto-reverse.ts
55274
+ init_protocol();
55275
+ function upsertPeerContact(args) {
55276
+ const now = args.now ?? Date.now;
55277
+ const grants = PERSONAL_CAP_GRANTS.map((g2) => ({
55278
+ resource: { ...g2.resource },
55279
+ actions: [...g2.actions]
55280
+ }));
55281
+ const existing = args.store.get(args.deviceId) ?? void 0;
55282
+ const base = {
55283
+ deviceId: args.deviceId,
55284
+ ownerId: args.ownerId,
55285
+ provider: args.provider,
55286
+ displayName: args.displayName,
55287
+ remoteUrl: args.selfUrl,
55288
+ connectToken: "",
55289
+ grants,
55290
+ addedAt: now(),
55291
+ pinnedAt: existing?.pinnedAt ?? null,
55292
+ remoteAccessAllowed: existing?.remoteAccessAllowed ?? false
55293
+ };
55294
+ args.store.upsert(base);
55295
+ args.broadcast({ type: "contact:added", contact: base });
55296
+ return base;
55297
+ }
55298
+ async function backfillConnectToken(args) {
55299
+ try {
55300
+ const token = await args.exchangeToken(args.contact.deviceId);
55301
+ if (!token) return;
55302
+ const upgraded = { ...args.contact, connectToken: token };
55303
+ args.store.upsert(upgraded);
55304
+ args.broadcast({ type: "contact:added", contact: upgraded });
55305
+ } catch {
55306
+ return;
55307
+ }
55308
+ }
55309
+
55310
+ // src/handlers/contact.ts
55281
55311
  function ensureOwner(ctx) {
55282
55312
  if (!ctx || ctx.principal.kind !== "owner") {
55283
55313
  throw new ClawdError(
@@ -55337,10 +55367,49 @@ function buildContactHandlers(deps) {
55337
55367
  }
55338
55368
  };
55339
55369
  };
55370
+ const handshake = async (frame, _client, ctx) => {
55371
+ if (!ctx || ctx.tokenKind !== "connect") {
55372
+ throw new ClawdError(
55373
+ ERROR_CODES.VALIDATION_ERROR,
55374
+ "contact:handshake \u4EC5\u63A5\u53D7 connect-token \u8FDE\u63A5\uFF08\u8BBE\u5907\u4E92\u8FDE\u63E1\u624B\u4E13\u7528\uFF09"
55375
+ );
55376
+ }
55377
+ const { type: _t, requestId: _r, ...rest } = frame;
55378
+ const args = ContactHandshakeArgsSchema.parse(rest);
55379
+ const contact = upsertPeerContact({
55380
+ store: deps.store,
55381
+ broadcast: deps.broadcast,
55382
+ deviceId: ctx.principal.id,
55383
+ ownerId: ctx.ownerId ?? "",
55384
+ provider: ctx.provider ?? "",
55385
+ displayName: ctx.principal.displayName ?? ctx.principal.id,
55386
+ selfUrl: args.selfUrl,
55387
+ now: deps.now
55388
+ });
55389
+ void backfillConnectToken({
55390
+ store: deps.store,
55391
+ broadcast: deps.broadcast,
55392
+ contact,
55393
+ exchangeToken: deps.exchangeToken
55394
+ });
55395
+ return {
55396
+ response: {
55397
+ type: "contact:handshake:ok",
55398
+ owner: {
55399
+ id: deps.ownerPrincipalId,
55400
+ ownerId: deps.ownerId,
55401
+ provider: deps.ownerProvider,
55402
+ displayName: deps.ownerDisplayName
55403
+ },
55404
+ grants: ctx.grants
55405
+ }
55406
+ };
55407
+ };
55340
55408
  return {
55341
55409
  "contact:list": list,
55342
55410
  "contact:pin": pin,
55343
- "contact:remove": remove
55411
+ "contact:remove": remove,
55412
+ "contact:handshake": handshake
55344
55413
  };
55345
55414
  }
55346
55415
 
@@ -55393,6 +55462,14 @@ function buildWhoamiHandler(deps) {
55393
55462
  if (!ctx) {
55394
55463
  throw new ClawdError(ERROR_CODES.INTERNAL, "whoami: missing ConnectionContext");
55395
55464
  }
55465
+ if (ctx.tokenKind === "connect") {
55466
+ return {
55467
+ response: {
55468
+ type: "whoami:error",
55469
+ message: "CLIENT_OUTDATED: \u8BF7\u5347\u7EA7\u672C\u673A clawd \u540E\u91CD\u8BD5"
55470
+ }
55471
+ };
55472
+ }
55396
55473
  const owner = {
55397
55474
  ...makeOwnerPrincipal(deps.ownerPrincipalId, deps.ownerDisplayName),
55398
55475
  ownerId: deps.ownerId,
@@ -55499,6 +55576,13 @@ function buildDeviceHandlers(deps) {
55499
55576
  ensureOwner3(ctx);
55500
55577
  const { type: _t, requestId: _r, ...rest } = frame;
55501
55578
  const args = DeviceConnectArgsSchema.parse(rest);
55579
+ const selfUrl = deps.selfUrl();
55580
+ if (!selfUrl) {
55581
+ throw new ClawdError(
55582
+ ERROR_CODES.VALIDATION_ERROR,
55583
+ "TUNNEL_REQUIRED: \u672C\u673A\u672A\u5F00 tunnel\uFF0C\u5F00\u542F\u540E\u91CD\u8BD5"
55584
+ );
55585
+ }
55502
55586
  const exchanged = await deps.exchange(args.deviceId);
55503
55587
  const url = args.url ?? exchanged.deviceUrl;
55504
55588
  if (!url) {
@@ -55509,14 +55593,7 @@ function buildDeviceHandlers(deps) {
55509
55593
  }
55510
55594
  let conn;
55511
55595
  try {
55512
- conn = await deps.connectRemote({
55513
- url,
55514
- token: exchanged.token,
55515
- // 自动反向(spec 决策 #11):自报本机可达地址,让对方反向加我为联系人;
55516
- // 本机无 tunnel → null → connectRemote 省略 selfUrl 字段(不造假数据)。
55517
- // 身份不自报:对方从 token 签名背书取我的 deviceId/ownerId/provider(决策 #16)。
55518
- selfUrl: deps.selfUrl() ?? void 0
55519
- });
55596
+ conn = await deps.connectRemote({ url, token: exchanged.token });
55520
55597
  } catch (e) {
55521
55598
  throw new ClawdError(
55522
55599
  ERROR_CODES.VALIDATION_ERROR,
@@ -55524,21 +55601,33 @@ function buildDeviceHandlers(deps) {
55524
55601
  );
55525
55602
  }
55526
55603
  try {
55527
- const wh = await conn.whoami();
55604
+ let hs;
55605
+ try {
55606
+ hs = await conn.handshake(selfUrl);
55607
+ } catch (e) {
55608
+ const msg = e.message;
55609
+ if (/unknown method|METHOD_NOT_ALLOWED/i.test(msg)) {
55610
+ throw new ClawdError(
55611
+ ERROR_CODES.VALIDATION_ERROR,
55612
+ "REVERSE_UNSUPPORTED: \u5BF9\u65B9 clawd \u7248\u672C\u8FC7\u65E7\uFF0C\u8BF7\u8BA9\u5BF9\u65B9\u5347\u7EA7\u540E\u91CD\u8BD5"
55613
+ );
55614
+ }
55615
+ throw e;
55616
+ }
55528
55617
  const existing = deps.store.get(args.deviceId) ?? void 0;
55529
55618
  const contact = {
55530
55619
  deviceId: args.deviceId,
55531
- ownerId: wh.ownerId,
55532
- provider: wh.provider,
55620
+ ownerId: hs.ownerId,
55621
+ provider: hs.provider,
55533
55622
  // 显示名优先用调用方传的 name:公共目录点击连接带 clawd_devices.name(对外展示名,
55534
- // 如"公共1");"输入标识"连接不带 name → 回落 whoami 报的对方 owner 真名。
55535
- // 归属人身份不丢(ownerId/provider 仍来自 whoami 背书)。
55536
- displayName: args.name || wh.displayName || args.deviceId,
55623
+ // 如"公共1");"输入标识"连接不带 name → 回落 handshake 报的对方 owner 真名。
55624
+ // 归属人身份不丢(ownerId/provider 仍来自 handshake 背书)。
55625
+ displayName: args.name || hs.displayName || args.deviceId,
55537
55626
  remoteUrl: url,
55538
55627
  // connectToken 存自包含签名 token(断线重连复用,30 天有效;
55539
55628
  // 过期后对方拒 TOKEN_EXPIRED → 重新 device:connect 换新票)
55540
55629
  connectToken: exchanged.token,
55541
- grants: wh.grants,
55630
+ grants: hs.grants,
55542
55631
  addedAt: now(),
55543
55632
  pinnedAt: existing?.pinnedAt ?? null,
55544
55633
  remoteAccessAllowed: existing?.remoteAccessAllowed ?? false
@@ -57983,7 +58072,12 @@ function buildMethodHandlers(deps) {
57983
58072
  ...buildContactHandlers({
57984
58073
  store: deps.contactStore,
57985
58074
  broadcast: deps.broadcastToOwners,
57986
- now: () => Date.now()
58075
+ now: () => Date.now(),
58076
+ ownerPrincipalId: deps.ownerPrincipalId,
58077
+ ownerDisplayName: deps.ownerDisplayName,
58078
+ ownerId: deps.ownerId,
58079
+ ownerProvider: deps.ownerProvider,
58080
+ exchangeToken: deps.exchangeToken
57987
58081
  }),
57988
58082
  ...buildContactRemoteAccessHandlers({
57989
58083
  store: deps.contactStore,
@@ -58329,6 +58423,9 @@ var METHOD_GRANT_MAP = {
58329
58423
  // contact:setRemoteAccess (owner UI 配远程访问授权):ADMIN_ANY
58330
58424
  // (对齐 inbox:postMessage 的"能连上=有 auth,业务在 handler 校"模式)
58331
58425
  "contact:setRemoteAccess": ADMIN_ANY,
58426
+ // contact:handshake: 能连上 = 已通过本地验票;handler 内校验 tokenKind==='connect'
58427
+ // (visitor / owner 不允许触发反向落库)。
58428
+ "contact:handshake": { kind: "public" },
58332
58429
  // ---- visitor:* (访客名单,owner-only) ----
58333
58430
  // owner 看完整访客名单(含没开会话的);guest 不可调(handler 内再 assertOwner 兜底)。
58334
58431
  "visitor:list": ADMIN_ANY,
@@ -58981,43 +59078,10 @@ async function startDaemon(config) {
58981
59078
  // Task 1.7:authenticate 注入路径替代 expectedToken 单 token 比对。
58982
59079
  // owner 路径 constantTimeEqual 防侧信道;guest 路径走 verifyConnectToken (Ed25519).
58983
59080
  expectedToken: resolvedAuthToken,
58984
- authenticate: async (t, selfUrl) => {
58985
- const result = await authenticate(t, buildConnectAuthDeps());
58986
- if (result.ok && result.context.principal.kind === "guest") {
58987
- void autoReverseContact({
58988
- store: contactStore,
58989
- broadcast: (frame) => wsServer?.broadcastToOwners(frame),
58990
- deviceId: result.context.principal.id,
58991
- ownerId: result.context.ownerId ?? "",
58992
- provider: result.context.provider ?? "",
58993
- displayName: result.context.principal.displayName ?? result.context.principal.id,
58994
- selfUrl,
58995
- // 决策 #16 换票补全:用 owner 当前 jwt 换 aud=对方设备 的票(本机主动连对方用)。
58996
- // 现读 ownerIdentityStore(热切换后用新身份);失败 → null → 保持空票。
58997
- exchangeToken: async (targetDeviceId) => {
58998
- const record = ownerIdentityStore.read();
58999
- if (!record) return null;
59000
- try {
59001
- const r = await centralExchange({
59002
- api: config.clawosApi ?? DEFAULT_CLAWOS_API,
59003
- jwt: record.ttcToken,
59004
- provider: record.identity.provider,
59005
- deviceId: targetDeviceId,
59006
- selfDeviceId: authFile.deviceId
59007
- });
59008
- return r.token;
59009
- } catch (e) {
59010
- logger.warn("auto-reverse exchange token failed", {
59011
- targetDeviceId,
59012
- err: e.message
59013
- });
59014
- return null;
59015
- }
59016
- }
59017
- });
59018
- }
59019
- return result;
59020
- },
59081
+ // 纯验票(spec 2026-07-22 双向确认建联):guest 入站 = 自包含 connect token
59082
+ // **本地验签**(Ed25519 公钥 + aud === 本机 deviceId + 未过期),零网络调用。
59083
+ // 反向落库不再挂在验票副作用上——落库唯一路径 = contact:handshake handler。
59084
+ authenticate: async (t) => authenticate(t, buildConnectAuthDeps()),
59021
59085
  onAuthed: (h, ctx) => wsServer?.attachClientContext(h.id, ctx),
59022
59086
  buildOwnerContext: () => ownerContext(ownerPrincipalId, ownerDisplayName, ownerFeishuUnionId),
59023
59087
  closeConnection: (h, code, reason) => wsServer?.closeClient(h.id, code, reason),
@@ -59521,8 +59585,30 @@ async function startDaemon(config) {
59521
59585
  // cascade 接 store (list 统计 deletedInboxEvents).
59522
59586
  inboxManager,
59523
59587
  inboxStore,
59524
- // 联系人列表 store(device:connect / 自动反向落同一 store)
59588
+ // 联系人列表 store(device:connect 出站 / contact:handshake 入站落同一 store)
59525
59589
  contactStore,
59590
+ // contact:handshake 换票回填(spec 2026-07-22):用 owner 当前 jwt 换 aud=对方设备 的票。
59591
+ // 现读 ownerIdentityStore(热切换后用新身份);失败 / 未登录 → null → 保持空票。
59592
+ exchangeToken: async (targetDeviceId) => {
59593
+ const record = ownerIdentityStore.read();
59594
+ if (!record) return null;
59595
+ try {
59596
+ const r = await centralExchange({
59597
+ api: config.clawosApi ?? DEFAULT_CLAWOS_API,
59598
+ jwt: record.ttcToken,
59599
+ provider: record.identity.provider,
59600
+ deviceId: targetDeviceId,
59601
+ selfDeviceId: authFile.deviceId
59602
+ });
59603
+ return r.token;
59604
+ } catch (e) {
59605
+ logger.warn("contact:handshake exchange token failed", {
59606
+ targetDeviceId,
59607
+ err: e.message
59608
+ });
59609
+ return null;
59610
+ }
59611
+ },
59526
59612
  // inbox:sendDm 用:sessionId → session 出身(复用 attachment 同款 findOwnedSessionScope)
59527
59613
  getSessionScope: (sid) => manager.findOwnedSessionScope(sid),
59528
59614
  // contact:removed broadcast;复用 capability:tokenIssued 同款通路
@@ -22009,13 +22009,13 @@ var AuthRequestFrameSchema = external_exports.object({
22009
22009
  * guest 的设备 id / 人 id / 来源全部由 connect token 的 subDevice/subOwner/provider 签名背书
22010
22010
  * (server 签、目标设备本地验签,不可伪造),不再接受任何自报身份。
22011
22011
  *
22012
- * selfUrl 保留:它只是"可达地址"(auto-reverse 反向连接用),不是身份——填错只影响
22013
- * 反向连通性,不构成伪造风险,且 url 不在 token 里(token 只签身份不签地址)。
22012
+ * selfUrl 已废弃(spec 2026-07-22 双向确认建联):可达地址自报移入 contact:handshake
22013
+ * args——auth 层回归纯验票。旧客户端发来的 selfUrl strip(zod 默认),无害;
22014
+ * 旧客户端本身由 whoami 的 connect-token 拒绝路径拦截(CLIENT_OUTDATED)。
22014
22015
  */
22015
- selfUrl: external_exports.string().min(1).optional(),
22016
22016
  /**
22017
22017
  * 客户端自报的 user-agent 性质标签(诊断/展示用)。
22018
- * selfUrl 性质的「自报事实」,非身份(决策 #16 边界):不参与鉴权、不参与路由、
22018
+ * 「自报事实」,非身份(决策 #16 边界):不参与鉴权、不参与路由、
22019
22019
  * 任何 handler 不得据此做权限判断。name 约定命名('clawd-desktop' / 'clawd-web' /
22020
22020
  * 'clawd-mobile-android' / ...),宽容未知值(将来加新客户端不动 schema)。
22021
22021
  * instanceId:客户端首启生成 UUID 并本地持久化——区分同种类多实例(多手机/多 desktop 同连)。
@@ -22427,6 +22427,24 @@ var ContactPinnedFrameSchema = external_exports.object({
22427
22427
  pinnedAt: external_exports.number().int().nullable()
22428
22428
  }).strict();
22429
22429
 
22430
+ // ../protocol/src/contact-handshake.ts
22431
+ var ContactHandshakeArgsSchema = external_exports.object({
22432
+ selfUrl: external_exports.string().min(1)
22433
+ });
22434
+ var ContactHandshakeOkSchema = external_exports.object({
22435
+ type: external_exports.literal("contact:handshake:ok"),
22436
+ owner: external_exports.object({
22437
+ /** 被调方 deviceId(路由主键;调用方落 Contact 的主键用自己请求的 args.deviceId,此值供校验/展示) */
22438
+ id: external_exports.string(),
22439
+ /** 归属人(落 Contact.ownerId / provider,显示分组用) */
22440
+ ownerId: external_exports.string(),
22441
+ provider: external_exports.string(),
22442
+ displayName: external_exports.string()
22443
+ }),
22444
+ /** 被调方授调用方的 grants(落 Contact.grants) */
22445
+ grants: external_exports.array(GrantSchema)
22446
+ });
22447
+
22430
22448
  // ../protocol/src/contact-remote-access.ts
22431
22449
  var ContactSetRemoteAccessArgsSchema = external_exports.object({
22432
22450
  deviceId: external_exports.string().min(1),