@clawos-dev/clawd 0.2.254 → 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();
@@ -51058,10 +51089,7 @@ var AuthGate = class {
51058
51089
  st.pending = true;
51059
51090
  let r;
51060
51091
  try {
51061
- r = await this.opts.authenticate(
51062
- parsed.data.token,
51063
- parsed.data.selfUrl
51064
- );
51092
+ r = await this.opts.authenticate(parsed.data.token);
51065
51093
  } finally {
51066
51094
  st.pending = false;
51067
51095
  }
@@ -51165,7 +51193,8 @@ function constantTimeEqual2(a, b2) {
51165
51193
  function ownerContext(ownerPrincipalId, displayName, feishuUnionId) {
51166
51194
  return {
51167
51195
  principal: makeOwnerPrincipal(ownerPrincipalId, displayName, feishuUnionId),
51168
- grants: [{ resource: { type: "*" }, actions: ["admin"] }]
51196
+ grants: [{ resource: { type: "*" }, actions: ["admin"] }],
51197
+ tokenKind: "owner"
51169
51198
  };
51170
51199
  }
51171
51200
  function connectGuestContext(subDevice, subOwner, provider, displayName) {
@@ -51175,6 +51204,7 @@ function connectGuestContext(subDevice, subOwner, provider, displayName) {
51175
51204
  resource: { ...g2.resource },
51176
51205
  actions: [...g2.actions]
51177
51206
  })),
51207
+ tokenKind: "connect",
51178
51208
  ownerId: subOwner,
51179
51209
  provider
51180
51210
  };
@@ -51186,6 +51216,7 @@ function visitorGuestContext(p2) {
51186
51216
  resource: { ...g2.resource },
51187
51217
  actions: [...g2.actions]
51188
51218
  })),
51219
+ tokenKind: "visitor",
51189
51220
  ownerId: p2.visitorId,
51190
51221
  provider: p2.provider
51191
51222
  };
@@ -51534,6 +51565,7 @@ var ContactStore = class {
51534
51565
 
51535
51566
  // src/contact/connect-remote.ts
51536
51567
  var crypto5 = __toESM(require("crypto"), 1);
51568
+ init_protocol();
51537
51569
  var HANDSHAKE_TIMEOUT_MS = 5e3;
51538
51570
  var RPC_TIMEOUT_MS = 5e3;
51539
51571
  async function connectRemote(args) {
@@ -51546,17 +51578,7 @@ async function connectRemote(args) {
51546
51578
  });
51547
51579
  ws.once("error", onError);
51548
51580
  });
51549
- ws.send(
51550
- JSON.stringify({
51551
- type: "auth",
51552
- token: args.token,
51553
- // 自动反向(spec 决策 #11):带本机可达地址时对方反向建联系人;
51554
- // 缺省时省略字段(不发空串),对方 schema selfUrl 是 .min(1).optional()。
51555
- // 决策 #16:身份字段(selfPrincipalId/selfDisplayName/selfDeviceId)已删——
51556
- // 对方从 connect token 签名背书取 deviceId/ownerId/provider,不信自报。
51557
- ...args.selfUrl ? { selfUrl: args.selfUrl } : {}
51558
- })
51559
- );
51581
+ ws.send(JSON.stringify({ type: "auth", token: args.token }));
51560
51582
  await new Promise((resolve6, reject) => {
51561
51583
  const onMessage = (raw) => {
51562
51584
  let f;
@@ -51608,7 +51630,7 @@ async function connectRemote(args) {
51608
51630
  }
51609
51631
  if (f.requestId !== requestId) return;
51610
51632
  ws.off("message", onMessage);
51611
- if (f.error || f.type?.endsWith(":error")) {
51633
+ if (f.error || f.type === "error" || f.type?.endsWith(":error")) {
51612
51634
  reject(new Error(f.message ?? f.error ?? "rpc error"));
51613
51635
  } else {
51614
51636
  resolve6(f);
@@ -51623,13 +51645,14 @@ async function connectRemote(args) {
51623
51645
  });
51624
51646
  }
51625
51647
  return {
51626
- whoami: async () => {
51627
- const f = await call("whoami", {});
51648
+ handshake: async (selfUrl) => {
51649
+ const raw = await call("contact:handshake", { selfUrl });
51650
+ const f = ContactHandshakeOkSchema.parse(raw);
51628
51651
  return {
51629
51652
  ownerId: f.owner.ownerId,
51630
51653
  provider: f.owner.provider,
51631
51654
  displayName: f.owner.displayName,
51632
- grants: f.capability.grants
51655
+ grants: f.grants
51633
51656
  };
51634
51657
  },
51635
51658
  close: () => {
@@ -51641,43 +51664,6 @@ async function connectRemote(args) {
51641
51664
  };
51642
51665
  }
51643
51666
 
51644
- // src/contact/auto-reverse.ts
51645
- init_protocol();
51646
- async function autoReverseContact(args) {
51647
- if (!args.selfUrl) return;
51648
- const now = args.now ?? Date.now;
51649
- const grants = PERSONAL_CAP_GRANTS.map((g2) => ({
51650
- resource: { ...g2.resource },
51651
- actions: [...g2.actions]
51652
- }));
51653
- const existing = args.store.get(args.deviceId) ?? void 0;
51654
- const base = {
51655
- deviceId: args.deviceId,
51656
- ownerId: args.ownerId,
51657
- provider: args.provider,
51658
- displayName: args.displayName,
51659
- remoteUrl: args.selfUrl,
51660
- connectToken: "",
51661
- grants,
51662
- addedAt: now(),
51663
- pinnedAt: existing?.pinnedAt ?? null,
51664
- remoteAccessAllowed: existing?.remoteAccessAllowed ?? false
51665
- };
51666
- args.store.upsert(base);
51667
- args.broadcast({ type: "contact:added", contact: base });
51668
- if (!args.exchangeToken) return;
51669
- let token;
51670
- try {
51671
- token = await args.exchangeToken(args.deviceId);
51672
- } catch {
51673
- return;
51674
- }
51675
- if (!token) return;
51676
- const upgraded = { ...base, connectToken: token };
51677
- args.store.upsert(upgraded);
51678
- args.broadcast({ type: "contact:added", contact: upgraded });
51679
- }
51680
-
51681
51667
  // src/migrations/2026-05-20-flatten-sessions.ts
51682
51668
  var fs36 = __toESM(require("fs"), 1);
51683
51669
  var path37 = __toESM(require("path"), 1);
@@ -55283,6 +55269,45 @@ function buildInboxHandlers(deps) {
55283
55269
 
55284
55270
  // src/handlers/contact.ts
55285
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
55286
55311
  function ensureOwner(ctx) {
55287
55312
  if (!ctx || ctx.principal.kind !== "owner") {
55288
55313
  throw new ClawdError(
@@ -55342,10 +55367,49 @@ function buildContactHandlers(deps) {
55342
55367
  }
55343
55368
  };
55344
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
+ };
55345
55408
  return {
55346
55409
  "contact:list": list,
55347
55410
  "contact:pin": pin,
55348
- "contact:remove": remove
55411
+ "contact:remove": remove,
55412
+ "contact:handshake": handshake
55349
55413
  };
55350
55414
  }
55351
55415
 
@@ -55398,6 +55462,14 @@ function buildWhoamiHandler(deps) {
55398
55462
  if (!ctx) {
55399
55463
  throw new ClawdError(ERROR_CODES.INTERNAL, "whoami: missing ConnectionContext");
55400
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
+ }
55401
55473
  const owner = {
55402
55474
  ...makeOwnerPrincipal(deps.ownerPrincipalId, deps.ownerDisplayName),
55403
55475
  ownerId: deps.ownerId,
@@ -55504,6 +55576,13 @@ function buildDeviceHandlers(deps) {
55504
55576
  ensureOwner3(ctx);
55505
55577
  const { type: _t, requestId: _r, ...rest } = frame;
55506
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
+ }
55507
55586
  const exchanged = await deps.exchange(args.deviceId);
55508
55587
  const url = args.url ?? exchanged.deviceUrl;
55509
55588
  if (!url) {
@@ -55514,14 +55593,7 @@ function buildDeviceHandlers(deps) {
55514
55593
  }
55515
55594
  let conn;
55516
55595
  try {
55517
- conn = await deps.connectRemote({
55518
- url,
55519
- token: exchanged.token,
55520
- // 自动反向(spec 决策 #11):自报本机可达地址,让对方反向加我为联系人;
55521
- // 本机无 tunnel → null → connectRemote 省略 selfUrl 字段(不造假数据)。
55522
- // 身份不自报:对方从 token 签名背书取我的 deviceId/ownerId/provider(决策 #16)。
55523
- selfUrl: deps.selfUrl() ?? void 0
55524
- });
55596
+ conn = await deps.connectRemote({ url, token: exchanged.token });
55525
55597
  } catch (e) {
55526
55598
  throw new ClawdError(
55527
55599
  ERROR_CODES.VALIDATION_ERROR,
@@ -55529,21 +55601,33 @@ function buildDeviceHandlers(deps) {
55529
55601
  );
55530
55602
  }
55531
55603
  try {
55532
- 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
+ }
55533
55617
  const existing = deps.store.get(args.deviceId) ?? void 0;
55534
55618
  const contact = {
55535
55619
  deviceId: args.deviceId,
55536
- ownerId: wh.ownerId,
55537
- provider: wh.provider,
55620
+ ownerId: hs.ownerId,
55621
+ provider: hs.provider,
55538
55622
  // 显示名优先用调用方传的 name:公共目录点击连接带 clawd_devices.name(对外展示名,
55539
- // 如"公共1");"输入标识"连接不带 name → 回落 whoami 报的对方 owner 真名。
55540
- // 归属人身份不丢(ownerId/provider 仍来自 whoami 背书)。
55541
- displayName: args.name || wh.displayName || args.deviceId,
55623
+ // 如"公共1");"输入标识"连接不带 name → 回落 handshake 报的对方 owner 真名。
55624
+ // 归属人身份不丢(ownerId/provider 仍来自 handshake 背书)。
55625
+ displayName: args.name || hs.displayName || args.deviceId,
55542
55626
  remoteUrl: url,
55543
55627
  // connectToken 存自包含签名 token(断线重连复用,30 天有效;
55544
55628
  // 过期后对方拒 TOKEN_EXPIRED → 重新 device:connect 换新票)
55545
55629
  connectToken: exchanged.token,
55546
- grants: wh.grants,
55630
+ grants: hs.grants,
55547
55631
  addedAt: now(),
55548
55632
  pinnedAt: existing?.pinnedAt ?? null,
55549
55633
  remoteAccessAllowed: existing?.remoteAccessAllowed ?? false
@@ -57988,7 +58072,12 @@ function buildMethodHandlers(deps) {
57988
58072
  ...buildContactHandlers({
57989
58073
  store: deps.contactStore,
57990
58074
  broadcast: deps.broadcastToOwners,
57991
- 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
57992
58081
  }),
57993
58082
  ...buildContactRemoteAccessHandlers({
57994
58083
  store: deps.contactStore,
@@ -58334,6 +58423,9 @@ var METHOD_GRANT_MAP = {
58334
58423
  // contact:setRemoteAccess (owner UI 配远程访问授权):ADMIN_ANY
58335
58424
  // (对齐 inbox:postMessage 的"能连上=有 auth,业务在 handler 校"模式)
58336
58425
  "contact:setRemoteAccess": ADMIN_ANY,
58426
+ // contact:handshake: 能连上 = 已通过本地验票;handler 内校验 tokenKind==='connect'
58427
+ // (visitor / owner 不允许触发反向落库)。
58428
+ "contact:handshake": { kind: "public" },
58337
58429
  // ---- visitor:* (访客名单,owner-only) ----
58338
58430
  // owner 看完整访客名单(含没开会话的);guest 不可调(handler 内再 assertOwner 兜底)。
58339
58431
  "visitor:list": ADMIN_ANY,
@@ -58986,43 +59078,10 @@ async function startDaemon(config) {
58986
59078
  // Task 1.7:authenticate 注入路径替代 expectedToken 单 token 比对。
58987
59079
  // owner 路径 constantTimeEqual 防侧信道;guest 路径走 verifyConnectToken (Ed25519).
58988
59080
  expectedToken: resolvedAuthToken,
58989
- authenticate: async (t, selfUrl) => {
58990
- const result = await authenticate(t, buildConnectAuthDeps());
58991
- if (result.ok && result.context.principal.kind === "guest") {
58992
- void autoReverseContact({
58993
- store: contactStore,
58994
- broadcast: (frame) => wsServer?.broadcastToOwners(frame),
58995
- deviceId: result.context.principal.id,
58996
- ownerId: result.context.ownerId ?? "",
58997
- provider: result.context.provider ?? "",
58998
- displayName: result.context.principal.displayName ?? result.context.principal.id,
58999
- selfUrl,
59000
- // 决策 #16 换票补全:用 owner 当前 jwt 换 aud=对方设备 的票(本机主动连对方用)。
59001
- // 现读 ownerIdentityStore(热切换后用新身份);失败 → null → 保持空票。
59002
- exchangeToken: async (targetDeviceId) => {
59003
- const record = ownerIdentityStore.read();
59004
- if (!record) return null;
59005
- try {
59006
- const r = await centralExchange({
59007
- api: config.clawosApi ?? DEFAULT_CLAWOS_API,
59008
- jwt: record.ttcToken,
59009
- provider: record.identity.provider,
59010
- deviceId: targetDeviceId,
59011
- selfDeviceId: authFile.deviceId
59012
- });
59013
- return r.token;
59014
- } catch (e) {
59015
- logger.warn("auto-reverse exchange token failed", {
59016
- targetDeviceId,
59017
- err: e.message
59018
- });
59019
- return null;
59020
- }
59021
- }
59022
- });
59023
- }
59024
- return result;
59025
- },
59081
+ // 纯验票(spec 2026-07-22 双向确认建联):guest 入站 = 自包含 connect token
59082
+ // **本地验签**(Ed25519 公钥 + aud === 本机 deviceId + 未过期),零网络调用。
59083
+ // 反向落库不再挂在验票副作用上——落库唯一路径 = contact:handshake handler。
59084
+ authenticate: async (t) => authenticate(t, buildConnectAuthDeps()),
59026
59085
  onAuthed: (h, ctx) => wsServer?.attachClientContext(h.id, ctx),
59027
59086
  buildOwnerContext: () => ownerContext(ownerPrincipalId, ownerDisplayName, ownerFeishuUnionId),
59028
59087
  closeConnection: (h, code, reason) => wsServer?.closeClient(h.id, code, reason),
@@ -59526,8 +59585,30 @@ async function startDaemon(config) {
59526
59585
  // cascade 接 store (list 统计 deletedInboxEvents).
59527
59586
  inboxManager,
59528
59587
  inboxStore,
59529
- // 联系人列表 store(device:connect / 自动反向落同一 store)
59588
+ // 联系人列表 store(device:connect 出站 / contact:handshake 入站落同一 store)
59530
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
+ },
59531
59612
  // inbox:sendDm 用:sessionId → session 出身(复用 attachment 同款 findOwnedSessionScope)
59532
59613
  getSessionScope: (sid) => manager.findOwnedSessionScope(sid),
59533
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),