@clawos-dev/clawd 0.2.254 → 0.2.256

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({
@@ -6208,7 +6238,13 @@ var init_dispatch = __esm({
6208
6238
  // 合法值集与 claude adapter capabilities.models[].id 一致(见 daemon
6209
6239
  // tools/claude.ts CLAUDE_MODEL_IDS);handler 层做二次校验,非法值 fail-fast。
6210
6240
  // protocol 层保持 string 便于未来跨 tool 扩展(届时 union 收合法字面量)。
6211
- model: external_exports.string().min(1).optional()
6241
+ model: external_exports.string().min(1).optional(),
6242
+ // 可选:给新 worker session 起 label(写入 SessionFile.label,sidebar 显示)。
6243
+ // 仅在**新开 dispatch**(dispatchId 缺席)时生效;续派(dispatchId 非空)
6244
+ // 时 daemon 层静默丢弃(避免半途改 label 造成 sidebar 抖动)。
6245
+ // 上限 60 字符——sidebar 显示 tolerance,卡在 dispatch 入口即可。
6246
+ // 协议层不硬拒 sessionLabel + dispatchId 同传,保留 forward-compat。
6247
+ sessionLabel: external_exports.string().min(1).max(60).optional()
6212
6248
  }).refine((v2) => v2.targetPersona !== void 0 || v2.dispatchId !== void 0, {
6213
6249
  message: "either targetPersona (new dispatch) or dispatchId (follow-up) is required"
6214
6250
  }).refine((v2) => !(v2.dispatchId !== void 0 && v2.targetDeviceId !== void 0), {
@@ -6315,6 +6351,7 @@ var init_runtime = __esm({
6315
6351
  init_capability();
6316
6352
  init_inbox();
6317
6353
  init_contact();
6354
+ init_contact_handshake();
6318
6355
  init_contact_remote_access();
6319
6356
  init_peer_exec();
6320
6357
  init_extension();
@@ -41901,20 +41938,23 @@ var ATTACHMENT_SHARING_HINT = `## \u628A\u4EA7\u51FA\u6587\u4EF6\u5206\u4EAB\u7E
41901
41938
  - \u53EA\u80FD\u5206\u4EAB\u4F60\u81EA\u5DF1\u5DE5\u4F5C\u76EE\u5F55\u5185\u7684\u6587\u4EF6\uFF0C\u8D8A\u754C\u4F1A\u88AB\u62D2\u3002
41902
41939
  - \u88AB dispatch \u63A5\u5230\u4EFB\u52A1\u3001\u8981\u56DE\u4F20\u4EA7\u7269\u65F6\uFF1A\u628A\u7B7E\u540D URL \u5199\u8FDB \`personaDispatchComplete\` tool\uFF08\`clawd-dispatch\` MCP\uFF09\u7684 \`text\` \u91CC\uFF08\`filePaths\` \u53EA\u662F\u4F60\u672C\u673A\u7684\u7EDD\u5BF9\u8DEF\u5F84\uFF0C\u8DE8\u8BBE\u5907\u5BF9\u65B9\u6253\u4E0D\u5F00\uFF0CURL \u624D\u80FD\u6253\u5F00\uFF09\u3002`;
41903
41940
 
41941
+ // src/persona/no-ask-user-question-prompt.ts
41942
+ var NO_ASK_USER_QUESTION_HINT = `## \u9700\u8981\u8001\u677F\u62CD\u677F\u65F6\u600E\u4E48\u95EE
41943
+
41944
+ \u8981\u8001\u677F\u9009\u62E9 / \u786E\u8BA4 / \u62CD\u677F\u65F6\uFF0C\u76F4\u63A5\u6B63\u6587\u5199\u95EE\u9898 + \u5217 A/B/C \u9009\u9879\u3002\u4E0D\u8981\u8C03 AskUserQuestion tool\u3002`;
41945
+
41904
41946
  // src/dispatch/system-prompt.ts
41905
41947
  var DISPATCH_SYSTEM_PROMPT_HINT = `## \u59D4\u6D3E\u7ED9\u53E6\u4E00\u4E2A persona\uFF08dispatch\uFF09
41906
41948
 
41907
- \u5F53\u7528\u6237\u6D88\u606F\u91CC\u542B \`@persona/<id>\`\uFF08\u5982 \`@persona/persona-app-builder\`\uFF09\u65F6\uFF0C**\u4F60\u5FC5\u987B**\u7ACB\u5373\u8C03 \`clawd-dispatch\` MCP server \u7684 \`personaDispatch\` tool \u59D4\u6D3E\uFF08\u5404 agent \u5BF9 MCP tool \u7684\u5C55\u793A\u540D\u4E0D\u540C\uFF0C\u5982 claude \u663E\u793A\u4E3A \`mcp__clawd-dispatch__personaDispatch\`\uFF1B\u6309\u4F60\u73AF\u5883\u91CC\u5B9E\u9645\u5217\u51FA\u7684\u540D\u5B57\u8C03\u7528\uFF09\uFF0C\u4E0D\u8981\u5C1D\u8BD5\u81EA\u5DF1\u6267\u884C\uFF1A
41949
+ \u5F53\u7528\u6237\u6D88\u606F\u91CC\u542B \`@persona/<id>\`\uFF08\u5982 \`@persona/persona-app-builder\`\uFF09\u65F6\uFF0C**\u4F60\u5FC5\u987B**\u7ACB\u5373\u8C03 \`clawd-dispatch\` MCP \u7684 \`personaDispatch\` tool \u59D4\u6D3E\uFF1A
41908
41950
 
41909
41951
  - \`targetPersona = <id>\`\uFF08@ token \u91CC\u7684 persona id\uFF09
41910
41952
  - \`prompt = \u53BB\u6389 @persona/<id> token \u7684\u7528\u6237\u539F\u6587\`
41911
41953
 
41912
- tool \u4F1A**\u7ACB\u5373\u8FD4\u56DE** dispatchId\uFF08\u5F02\u6B65\u59D4\u6D3E\uFF09\uFF0C\u5BF9\u65B9\u4EBA\u683C\u5728\u540E\u53F0\u5E72\u6D3B\u3002\u4F60\u544A\u8BC9\u7528\u6237"\u4EFB\u52A1\u5DF2\u4EA4\u529E"\u5373\u53EF\u7ED3\u675F\u672C\u8F6E\u2014\u2014**\u4E0D\u8981\u7B49\u5F85\u3001\u4E0D\u8981\u8F6E\u8BE2**\u3002\u5BF9\u65B9\u5E72\u5B8C\u540E\uFF0C\u7ED3\u679C\u4F1A\u4F5C\u4E3A\u4E00\u6761\u65B0\u6D88\u606F\u81EA\u52A8\u6CE8\u5165\u56DE\u4F60\u7684 session\uFF0C\u4F60\u5C4A\u65F6\u518D\u7EFC\u5408\u8F6C\u8FBE\u7ED9\u7528\u6237\u3002\u60F3\u67E5\u8FDB\u5C55\u65F6\uFF08\u7528\u6237\u95EE\u8D77\uFF09\u8C03\u540C\u4E00 MCP \u7684 \`personaDispatchList\`\u3002
41954
+ \u544A\u8BC9\u7528\u6237"\u4EFB\u52A1\u5DF2\u4EA4\u529E"\u5373\u53EF\u7ED3\u675F\u672C\u8F6E\u3002\u7ED3\u679C\u4F1A\u4F5C\u4E3A\u4E00\u6761\u65B0\u6D88\u606F\u81EA\u52A8\u6CE8\u5165\u56DE\u4F60\u7684 session\uFF0C\u5C4A\u65F6\u518D\u7EFC\u5408\u8F6C\u8FBE\u7ED9\u7528\u6237\u3002\u60F3\u67E5\u8FDB\u5C55\u65F6\uFF08\u7528\u6237\u95EE\u8D77\uFF09\u8C03\u540C\u4E00 MCP \u7684 \`personaDispatchList\`\u3002
41913
41955
 
41914
- - **\u4E0D\u8981**\u5C1D\u8BD5\u81EA\u5DF1\u6267\u884C\uFF08\u4F60\u4E0D\u662F\u90A3\u4E2A persona\uFF09
41915
- - **\u4E0D\u8981** in-place \u6A21\u4EFF\u5BF9\u65B9\u4EBA\u683C \u2014\u2014 dispatch \u662F\u771F\u5207\u5230\u53E6\u4E00\u4E2A\u72EC\u7ACB session
41956
+ - **\u4E0D\u8981**\u5C1D\u8BD5\u81EA\u5DF1\u6267\u884C\uFF08\u4F60\u4E0D\u662F\u90A3\u4E2A persona\uFF09\uFF0C\u4E5F\u4E0D\u8981 in-place \u6A21\u4EFF\u5BF9\u65B9\u4EBA\u683C \u2014\u2014 dispatch \u662F\u771F\u5207\u5230\u53E6\u4E00\u4E2A\u72EC\u7ACB session
41916
41957
  - \u4E00\u53E5\u8BDD\u91CC\u591A\u4E2A \`@persona/<id>\` \u65F6\u9009\u7B2C\u4E00\u4E2A\uFF08tool \u4E00\u6B21\u53EA\u5904\u7406\u4E00\u4E2A target\uFF09
41917
- - \u59D4\u6D3E\u5931\u8D25\uFF08tool_result.is_error=true\uFF09\u65F6\u5982\u5B9E\u544A\u8BC9\u7528\u6237\u539F\u56E0\uFF0C**\u4E0D\u8981\u91CD\u8BD5**
41918
41958
 
41919
41959
  ### \u7EED\u6D3E\uFF08\u540C\u4E00\u4EF6\u4E8B\u7684\u540E\u7EED\u6307\u4EE4\uFF09
41920
41960
 
@@ -41922,15 +41962,15 @@ tool \u4F1A**\u7ACB\u5373\u8FD4\u56DE** dispatchId\uFF08\u5F02\u6B65\u59D4\u6D3E
41922
41962
 
41923
41963
  ### \u8DE8\u8BBE\u5907\u59D4\u6D3E\uFF08remote persona\uFF09
41924
41964
 
41925
- \u5F53 @ token \u5F62\u5982 \`@persona/<id>@<deviceId>\`\uFF08\u7B2C\u4E8C\u6BB5\u662F\u8054\u7CFB\u4EBA**\u8BBE\u5907\u7684 deviceId**\uFF0CUI \u9009\u8054\u7CFB\u4EBA\u65F6\u5DF2\u89E3\u6790\u597D\u586B\u5165\uFF09\u65F6\uFF0C\u662F\u628A\u4EFB\u52A1\u59D4\u6D3E\u7ED9\u90A3\u53F0\u8BBE\u5907\u4E0A\u7684 persona\u3002\u9664 targetPersona / prompt \u5916\u518D\u5E26\u4E00\u4E2A\u53C2\u6570\uFF1A
41965
+ \u5F53 @ token \u5F62\u5982 \`@persona/<id>@<deviceId>\`\uFF08\u7B2C\u4E8C\u6BB5\u662F\u8054\u7CFB\u4EBA**\u8BBE\u5907\u7684 deviceId**\uFF0CUI \u9009\u8054\u7CFB\u4EBA\u65F6\u5DF2\u89E3\u6790\u597D\u586B\u5165\uFF09\u65F6\uFF0C\u662F\u628A\u4EFB\u52A1\u59D4\u6D3E\u7ED9\u90A3\u53F0\u8BBE\u5907\u4E0A\u7684 persona\u3002\u9664 targetPersona / prompt \u5916\u518D\u5E26\uFF1A
41926
41966
 
41927
41967
  - \`targetDeviceId = <token \u7B2C\u4E8C\u6BB5\u7684 deviceId\uFF0C\u539F\u6837\u586B>\`
41928
41968
 
41929
- deviceId \u76F4\u63A5\u53D6\u81EA token\uFF0C\u4E0D\u8981\u6539\u5199\u6216\u731C\u6D4B\u3002\u5BF9\u65B9\u4E0D\u53EF\u8FBE / \u76EE\u6807 persona \u672A\u516C\u5F00\u4F1A\u5728\u7ED3\u679C\u56DE\u6D41\u65F6\u62A5\u5931\u8D25\uFF0C\u7167\u5E38\u8F6C\u8FF0\u7ED9\u7528\u6237\u5373\u53EF\u3002\u5176\u4F59\u884C\u4E3A\uFF08\u79D2\u56DE dispatchId\u3001\u7ED3\u679C\u81EA\u52A8\u6CE8\u5165\u3001\u5931\u8D25\u4E0D\u91CD\u8BD5\uFF09\u4E0E\u672C\u5730\u59D4\u6D3E\u4E00\u81F4\u3002
41969
+ deviceId \u76F4\u63A5\u53D6\u81EA token\uFF0C\u4E0D\u8981\u6539\u5199\u6216\u731C\u6D4B\u3002
41930
41970
 
41931
41971
  ## \u88AB dispatch \u63A5\u5230\u4EFB\u52A1
41932
41972
 
41933
- \u5982\u679C\u4F60\u7684 session \u7B2C\u4E00\u6761 user message \u662F\u4EE5 \`[Dispatched from owner \u2014 another persona session\u59D4\u6D3E\u7ED9\u4F60\u6267\u884C\u4EFB\u52A1]\` \u8D77\u5934\u7684 plain text \u4EFB\u52A1\u5305\uFF0C\u4F60\u662F\u88AB\u53E6\u4E00\u4E2A persona \u59D4\u6D3E\u6765\u5E72\u6D3B\u7684\uFF08\u4EFB\u52A1\u5305\u4E0D\u7528 \`<system-reminder>\` \u5305\u662F\u4E3A\u4E86 reducer dedup \u5DE5\u4F5C\u6B63\u5E38\uFF0C\u89C1 manager.ts buildDispatchTaskPack \u6CE8\u91CA\uFF09\uFF1A
41973
+ \u5982\u679C\u4F60\u7684 session \u7B2C\u4E00\u6761 user message \u662F\u4EE5 \`[Dispatched from owner \u2014 another persona session\u59D4\u6D3E\u7ED9\u4F60\u6267\u884C\u4EFB\u52A1]\` \u8D77\u5934\u7684 plain text \u4EFB\u52A1\u5305\uFF0C\u4F60\u662F\u88AB\u53E6\u4E00\u4E2A persona \u59D4\u6D3E\u6765\u5E72\u6D3B\u7684\uFF1A
41934
41974
 
41935
41975
  - \u4EFB\u52A1\u5728 \`Task:\` \u6BB5
41936
41976
  - \u539F\u4F1A\u8BDD\u7684\u5BF9\u8BDD\u5386\u53F2\u5728 \`Source conversation\` \u6BB5\u7ED9\u7684 jsonl \u8DEF\u5F84\u4E0B\uFF1B\u6309\u9700 Read \u5B83\u4E86\u89E3\u4E0A\u4E0B\u6587\uFF08\u4E0D\u5FC5\u8BFB\u5B8C\uFF09
@@ -42095,6 +42135,9 @@ function buildSpawnContext(state, deps) {
42095
42135
  if (daemonUrl) {
42096
42136
  ctx.extraSystemPrompt = (ctx.extraSystemPrompt ? ctx.extraSystemPrompt + "\n\n" : "") + ATTACHMENT_SHARING_HINT;
42097
42137
  }
42138
+ if (meta?.personaMode && (file.tool ?? "claude") === "claude") {
42139
+ ctx.extraSystemPrompt = (ctx.extraSystemPrompt ? ctx.extraSystemPrompt + "\n\n" : "") + NO_ASK_USER_QUESTION_HINT;
42140
+ }
42098
42141
  if (meta?.extraSettings) {
42099
42142
  ctx.extraSettings = meta.extraSettings;
42100
42143
  }
@@ -44727,6 +44770,8 @@ var SessionManager = class {
44727
44770
  } : {},
44728
44771
  // T-20:dispatch model override 落盘(reducer spawn 路径读 SessionFile.model 生成 --model flag)
44729
44772
  ...args.model ? { model: args.model } : {},
44773
+ // butler 顶部入口:dispatch 传下来的 label 落盘,sidebar 显示(否则兜底 cwd-basename)
44774
+ ...args.label ? { label: args.label } : {},
44730
44775
  createdAt: now,
44731
44776
  updatedAt: now
44732
44777
  };
@@ -49384,7 +49429,10 @@ function buildPersonaDispatchHandlers(deps) {
49384
49429
  guestDisplayName: ctx.principal.displayName,
49385
49430
  // T-31: 把 A 的飞书 union_id 透传给 B(connectGuestContext 已把它挂在 principal.feishuUnionId)
49386
49431
  ...ctx.principal.feishuUnionId ? { guestFeishuUnionId: ctx.principal.feishuUnionId } : {},
49387
- ...args.model ? { model: args.model } : {}
49432
+ ...args.model ? { model: args.model } : {},
49433
+ // butler 顶部入口:peer daemon 若在 forwardToPeer 里传了 sessionLabel(当前
49434
+ // 未接线,作 forward-compat 预留),这里也透给 spawnB
49435
+ ...args.sessionLabel ? { label: args.sessionLabel } : {}
49388
49436
  }).then(() => logger?.info("dispatch.spawnB.ok", { dispatchId: dispatchId2 })).catch((err) => {
49389
49437
  const reason = err instanceof Error ? err.message : String(err);
49390
49438
  logger?.warn("dispatch.spawnB.failed", { dispatchId: dispatchId2, reason });
@@ -49419,7 +49467,9 @@ function buildPersonaDispatchHandlers(deps) {
49419
49467
  sourceSessionId,
49420
49468
  targetPersona,
49421
49469
  prompt: args.prompt,
49422
- ...args.model ? { model: args.model } : {}
49470
+ ...args.model ? { model: args.model } : {},
49471
+ // butler 顶部入口(spec 2026-07-27):sessionLabel 落到 SessionFile.label 让 sidebar 显示
49472
+ ...args.sessionLabel ? { label: args.sessionLabel } : {}
49423
49473
  }).then(() => {
49424
49474
  logger?.info("dispatch.spawnB.ok", { dispatchId });
49425
49475
  }).catch((err) => {
@@ -51058,10 +51108,7 @@ var AuthGate = class {
51058
51108
  st.pending = true;
51059
51109
  let r;
51060
51110
  try {
51061
- r = await this.opts.authenticate(
51062
- parsed.data.token,
51063
- parsed.data.selfUrl
51064
- );
51111
+ r = await this.opts.authenticate(parsed.data.token);
51065
51112
  } finally {
51066
51113
  st.pending = false;
51067
51114
  }
@@ -51165,7 +51212,8 @@ function constantTimeEqual2(a, b2) {
51165
51212
  function ownerContext(ownerPrincipalId, displayName, feishuUnionId) {
51166
51213
  return {
51167
51214
  principal: makeOwnerPrincipal(ownerPrincipalId, displayName, feishuUnionId),
51168
- grants: [{ resource: { type: "*" }, actions: ["admin"] }]
51215
+ grants: [{ resource: { type: "*" }, actions: ["admin"] }],
51216
+ tokenKind: "owner"
51169
51217
  };
51170
51218
  }
51171
51219
  function connectGuestContext(subDevice, subOwner, provider, displayName) {
@@ -51175,6 +51223,7 @@ function connectGuestContext(subDevice, subOwner, provider, displayName) {
51175
51223
  resource: { ...g2.resource },
51176
51224
  actions: [...g2.actions]
51177
51225
  })),
51226
+ tokenKind: "connect",
51178
51227
  ownerId: subOwner,
51179
51228
  provider
51180
51229
  };
@@ -51186,6 +51235,7 @@ function visitorGuestContext(p2) {
51186
51235
  resource: { ...g2.resource },
51187
51236
  actions: [...g2.actions]
51188
51237
  })),
51238
+ tokenKind: "visitor",
51189
51239
  ownerId: p2.visitorId,
51190
51240
  provider: p2.provider
51191
51241
  };
@@ -51534,6 +51584,7 @@ var ContactStore = class {
51534
51584
 
51535
51585
  // src/contact/connect-remote.ts
51536
51586
  var crypto5 = __toESM(require("crypto"), 1);
51587
+ init_protocol();
51537
51588
  var HANDSHAKE_TIMEOUT_MS = 5e3;
51538
51589
  var RPC_TIMEOUT_MS = 5e3;
51539
51590
  async function connectRemote(args) {
@@ -51546,17 +51597,7 @@ async function connectRemote(args) {
51546
51597
  });
51547
51598
  ws.once("error", onError);
51548
51599
  });
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
- );
51600
+ ws.send(JSON.stringify({ type: "auth", token: args.token }));
51560
51601
  await new Promise((resolve6, reject) => {
51561
51602
  const onMessage = (raw) => {
51562
51603
  let f;
@@ -51608,7 +51649,7 @@ async function connectRemote(args) {
51608
51649
  }
51609
51650
  if (f.requestId !== requestId) return;
51610
51651
  ws.off("message", onMessage);
51611
- if (f.error || f.type?.endsWith(":error")) {
51652
+ if (f.error || f.type === "error" || f.type?.endsWith(":error")) {
51612
51653
  reject(new Error(f.message ?? f.error ?? "rpc error"));
51613
51654
  } else {
51614
51655
  resolve6(f);
@@ -51623,13 +51664,14 @@ async function connectRemote(args) {
51623
51664
  });
51624
51665
  }
51625
51666
  return {
51626
- whoami: async () => {
51627
- const f = await call("whoami", {});
51667
+ handshake: async (selfUrl) => {
51668
+ const raw = await call("contact:handshake", { selfUrl });
51669
+ const f = ContactHandshakeOkSchema.parse(raw);
51628
51670
  return {
51629
51671
  ownerId: f.owner.ownerId,
51630
51672
  provider: f.owner.provider,
51631
51673
  displayName: f.owner.displayName,
51632
- grants: f.capability.grants
51674
+ grants: f.grants
51633
51675
  };
51634
51676
  },
51635
51677
  close: () => {
@@ -51641,43 +51683,6 @@ async function connectRemote(args) {
51641
51683
  };
51642
51684
  }
51643
51685
 
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
51686
  // src/migrations/2026-05-20-flatten-sessions.ts
51682
51687
  var fs36 = __toESM(require("fs"), 1);
51683
51688
  var path37 = __toESM(require("path"), 1);
@@ -55283,6 +55288,45 @@ function buildInboxHandlers(deps) {
55283
55288
 
55284
55289
  // src/handlers/contact.ts
55285
55290
  init_protocol();
55291
+
55292
+ // src/contact/auto-reverse.ts
55293
+ init_protocol();
55294
+ function upsertPeerContact(args) {
55295
+ const now = args.now ?? Date.now;
55296
+ const grants = PERSONAL_CAP_GRANTS.map((g2) => ({
55297
+ resource: { ...g2.resource },
55298
+ actions: [...g2.actions]
55299
+ }));
55300
+ const existing = args.store.get(args.deviceId) ?? void 0;
55301
+ const base = {
55302
+ deviceId: args.deviceId,
55303
+ ownerId: args.ownerId,
55304
+ provider: args.provider,
55305
+ displayName: args.displayName,
55306
+ remoteUrl: args.selfUrl,
55307
+ connectToken: "",
55308
+ grants,
55309
+ addedAt: now(),
55310
+ pinnedAt: existing?.pinnedAt ?? null,
55311
+ remoteAccessAllowed: existing?.remoteAccessAllowed ?? false
55312
+ };
55313
+ args.store.upsert(base);
55314
+ args.broadcast({ type: "contact:added", contact: base });
55315
+ return base;
55316
+ }
55317
+ async function backfillConnectToken(args) {
55318
+ try {
55319
+ const token = await args.exchangeToken(args.contact.deviceId);
55320
+ if (!token) return;
55321
+ const upgraded = { ...args.contact, connectToken: token };
55322
+ args.store.upsert(upgraded);
55323
+ args.broadcast({ type: "contact:added", contact: upgraded });
55324
+ } catch {
55325
+ return;
55326
+ }
55327
+ }
55328
+
55329
+ // src/handlers/contact.ts
55286
55330
  function ensureOwner(ctx) {
55287
55331
  if (!ctx || ctx.principal.kind !== "owner") {
55288
55332
  throw new ClawdError(
@@ -55342,10 +55386,49 @@ function buildContactHandlers(deps) {
55342
55386
  }
55343
55387
  };
55344
55388
  };
55389
+ const handshake = async (frame, _client, ctx) => {
55390
+ if (!ctx || ctx.tokenKind !== "connect") {
55391
+ throw new ClawdError(
55392
+ ERROR_CODES.VALIDATION_ERROR,
55393
+ "contact:handshake \u4EC5\u63A5\u53D7 connect-token \u8FDE\u63A5\uFF08\u8BBE\u5907\u4E92\u8FDE\u63E1\u624B\u4E13\u7528\uFF09"
55394
+ );
55395
+ }
55396
+ const { type: _t, requestId: _r, ...rest } = frame;
55397
+ const args = ContactHandshakeArgsSchema.parse(rest);
55398
+ const contact = upsertPeerContact({
55399
+ store: deps.store,
55400
+ broadcast: deps.broadcast,
55401
+ deviceId: ctx.principal.id,
55402
+ ownerId: ctx.ownerId ?? "",
55403
+ provider: ctx.provider ?? "",
55404
+ displayName: ctx.principal.displayName ?? ctx.principal.id,
55405
+ selfUrl: args.selfUrl,
55406
+ now: deps.now
55407
+ });
55408
+ void backfillConnectToken({
55409
+ store: deps.store,
55410
+ broadcast: deps.broadcast,
55411
+ contact,
55412
+ exchangeToken: deps.exchangeToken
55413
+ });
55414
+ return {
55415
+ response: {
55416
+ type: "contact:handshake:ok",
55417
+ owner: {
55418
+ id: deps.ownerPrincipalId,
55419
+ ownerId: deps.ownerId,
55420
+ provider: deps.ownerProvider,
55421
+ displayName: deps.ownerDisplayName
55422
+ },
55423
+ grants: ctx.grants
55424
+ }
55425
+ };
55426
+ };
55345
55427
  return {
55346
55428
  "contact:list": list,
55347
55429
  "contact:pin": pin,
55348
- "contact:remove": remove
55430
+ "contact:remove": remove,
55431
+ "contact:handshake": handshake
55349
55432
  };
55350
55433
  }
55351
55434
 
@@ -55398,6 +55481,14 @@ function buildWhoamiHandler(deps) {
55398
55481
  if (!ctx) {
55399
55482
  throw new ClawdError(ERROR_CODES.INTERNAL, "whoami: missing ConnectionContext");
55400
55483
  }
55484
+ if (ctx.tokenKind === "connect") {
55485
+ return {
55486
+ response: {
55487
+ type: "whoami:error",
55488
+ message: "CLIENT_OUTDATED: \u8BF7\u5347\u7EA7\u672C\u673A clawd \u540E\u91CD\u8BD5"
55489
+ }
55490
+ };
55491
+ }
55401
55492
  const owner = {
55402
55493
  ...makeOwnerPrincipal(deps.ownerPrincipalId, deps.ownerDisplayName),
55403
55494
  ownerId: deps.ownerId,
@@ -55504,6 +55595,13 @@ function buildDeviceHandlers(deps) {
55504
55595
  ensureOwner3(ctx);
55505
55596
  const { type: _t, requestId: _r, ...rest } = frame;
55506
55597
  const args = DeviceConnectArgsSchema.parse(rest);
55598
+ const selfUrl = deps.selfUrl();
55599
+ if (!selfUrl) {
55600
+ throw new ClawdError(
55601
+ ERROR_CODES.VALIDATION_ERROR,
55602
+ "TUNNEL_REQUIRED: \u672C\u673A\u672A\u5F00 tunnel\uFF0C\u5F00\u542F\u540E\u91CD\u8BD5"
55603
+ );
55604
+ }
55507
55605
  const exchanged = await deps.exchange(args.deviceId);
55508
55606
  const url = args.url ?? exchanged.deviceUrl;
55509
55607
  if (!url) {
@@ -55514,14 +55612,7 @@ function buildDeviceHandlers(deps) {
55514
55612
  }
55515
55613
  let conn;
55516
55614
  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
- });
55615
+ conn = await deps.connectRemote({ url, token: exchanged.token });
55525
55616
  } catch (e) {
55526
55617
  throw new ClawdError(
55527
55618
  ERROR_CODES.VALIDATION_ERROR,
@@ -55529,21 +55620,33 @@ function buildDeviceHandlers(deps) {
55529
55620
  );
55530
55621
  }
55531
55622
  try {
55532
- const wh = await conn.whoami();
55623
+ let hs;
55624
+ try {
55625
+ hs = await conn.handshake(selfUrl);
55626
+ } catch (e) {
55627
+ const msg = e.message;
55628
+ if (/unknown method|METHOD_NOT_ALLOWED/i.test(msg)) {
55629
+ throw new ClawdError(
55630
+ ERROR_CODES.VALIDATION_ERROR,
55631
+ "REVERSE_UNSUPPORTED: \u5BF9\u65B9 clawd \u7248\u672C\u8FC7\u65E7\uFF0C\u8BF7\u8BA9\u5BF9\u65B9\u5347\u7EA7\u540E\u91CD\u8BD5"
55632
+ );
55633
+ }
55634
+ throw e;
55635
+ }
55533
55636
  const existing = deps.store.get(args.deviceId) ?? void 0;
55534
55637
  const contact = {
55535
55638
  deviceId: args.deviceId,
55536
- ownerId: wh.ownerId,
55537
- provider: wh.provider,
55639
+ ownerId: hs.ownerId,
55640
+ provider: hs.provider,
55538
55641
  // 显示名优先用调用方传的 name:公共目录点击连接带 clawd_devices.name(对外展示名,
55539
- // 如"公共1");"输入标识"连接不带 name → 回落 whoami 报的对方 owner 真名。
55540
- // 归属人身份不丢(ownerId/provider 仍来自 whoami 背书)。
55541
- displayName: args.name || wh.displayName || args.deviceId,
55642
+ // 如"公共1");"输入标识"连接不带 name → 回落 handshake 报的对方 owner 真名。
55643
+ // 归属人身份不丢(ownerId/provider 仍来自 handshake 背书)。
55644
+ displayName: args.name || hs.displayName || args.deviceId,
55542
55645
  remoteUrl: url,
55543
55646
  // connectToken 存自包含签名 token(断线重连复用,30 天有效;
55544
55647
  // 过期后对方拒 TOKEN_EXPIRED → 重新 device:connect 换新票)
55545
55648
  connectToken: exchanged.token,
55546
- grants: wh.grants,
55649
+ grants: hs.grants,
55547
55650
  addedAt: now(),
55548
55651
  pinnedAt: existing?.pinnedAt ?? null,
55549
55652
  remoteAccessAllowed: existing?.remoteAccessAllowed ?? false
@@ -57988,7 +58091,12 @@ function buildMethodHandlers(deps) {
57988
58091
  ...buildContactHandlers({
57989
58092
  store: deps.contactStore,
57990
58093
  broadcast: deps.broadcastToOwners,
57991
- now: () => Date.now()
58094
+ now: () => Date.now(),
58095
+ ownerPrincipalId: deps.ownerPrincipalId,
58096
+ ownerDisplayName: deps.ownerDisplayName,
58097
+ ownerId: deps.ownerId,
58098
+ ownerProvider: deps.ownerProvider,
58099
+ exchangeToken: deps.exchangeToken
57992
58100
  }),
57993
58101
  ...buildContactRemoteAccessHandlers({
57994
58102
  store: deps.contactStore,
@@ -58334,6 +58442,9 @@ var METHOD_GRANT_MAP = {
58334
58442
  // contact:setRemoteAccess (owner UI 配远程访问授权):ADMIN_ANY
58335
58443
  // (对齐 inbox:postMessage 的"能连上=有 auth,业务在 handler 校"模式)
58336
58444
  "contact:setRemoteAccess": ADMIN_ANY,
58445
+ // contact:handshake: 能连上 = 已通过本地验票;handler 内校验 tokenKind==='connect'
58446
+ // (visitor / owner 不允许触发反向落库)。
58447
+ "contact:handshake": { kind: "public" },
58337
58448
  // ---- visitor:* (访客名单,owner-only) ----
58338
58449
  // owner 看完整访客名单(含没开会话的);guest 不可调(handler 内再 assertOwner 兜底)。
58339
58450
  "visitor:list": ADMIN_ANY,
@@ -58986,43 +59097,10 @@ async function startDaemon(config) {
58986
59097
  // Task 1.7:authenticate 注入路径替代 expectedToken 单 token 比对。
58987
59098
  // owner 路径 constantTimeEqual 防侧信道;guest 路径走 verifyConnectToken (Ed25519).
58988
59099
  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
- },
59100
+ // 纯验票(spec 2026-07-22 双向确认建联):guest 入站 = 自包含 connect token
59101
+ // **本地验签**(Ed25519 公钥 + aud === 本机 deviceId + 未过期),零网络调用。
59102
+ // 反向落库不再挂在验票副作用上——落库唯一路径 = contact:handshake handler。
59103
+ authenticate: async (t) => authenticate(t, buildConnectAuthDeps()),
59026
59104
  onAuthed: (h, ctx) => wsServer?.attachClientContext(h.id, ctx),
59027
59105
  buildOwnerContext: () => ownerContext(ownerPrincipalId, ownerDisplayName, ownerFeishuUnionId),
59028
59106
  closeConnection: (h, code, reason) => wsServer?.closeClient(h.id, code, reason),
@@ -59526,8 +59604,30 @@ async function startDaemon(config) {
59526
59604
  // cascade 接 store (list 统计 deletedInboxEvents).
59527
59605
  inboxManager,
59528
59606
  inboxStore,
59529
- // 联系人列表 store(device:connect / 自动反向落同一 store)
59607
+ // 联系人列表 store(device:connect 出站 / contact:handshake 入站落同一 store)
59530
59608
  contactStore,
59609
+ // contact:handshake 换票回填(spec 2026-07-22):用 owner 当前 jwt 换 aud=对方设备 的票。
59610
+ // 现读 ownerIdentityStore(热切换后用新身份);失败 / 未登录 → null → 保持空票。
59611
+ exchangeToken: async (targetDeviceId) => {
59612
+ const record = ownerIdentityStore.read();
59613
+ if (!record) return null;
59614
+ try {
59615
+ const r = await centralExchange({
59616
+ api: config.clawosApi ?? DEFAULT_CLAWOS_API,
59617
+ jwt: record.ttcToken,
59618
+ provider: record.identity.provider,
59619
+ deviceId: targetDeviceId,
59620
+ selfDeviceId: authFile.deviceId
59621
+ });
59622
+ return r.token;
59623
+ } catch (e) {
59624
+ logger.warn("contact:handshake exchange token failed", {
59625
+ targetDeviceId,
59626
+ err: e.message
59627
+ });
59628
+ return null;
59629
+ }
59630
+ },
59531
59631
  // inbox:sendDm 用:sessionId → session 出身(复用 attachment 同款 findOwnedSessionScope)
59532
59632
  getSessionScope: (sid) => manager.findOwnedSessionScope(sid),
59533
59633
  // contact:removed broadcast;复用 capability:tokenIssued 同款通路
@@ -59692,7 +59792,9 @@ async function startDaemon(config) {
59692
59792
  // T-31: 把 A 的飞书 union_id stamp 到 B SessionFile,供 cc prompt 身份卡 union_id 行
59693
59793
  guestFeishuUnionId: args.guestFeishuUnionId,
59694
59794
  // T-20: 可选 model override 透到 SessionFile.model
59695
- ...args.model ? { model: args.model } : {}
59795
+ ...args.model ? { model: args.model } : {},
59796
+ // butler 顶部入口(spec 2026-07-27):可选 label 透到 SessionFile.label(sidebar 显示)
59797
+ ...args.label ? { label: args.label } : {}
59696
59798
  });
59697
59799
  },
59698
59800
  // A 角色:从 ContactStore 取 peer 可达 URL + connect token,转发到对端 daemon /rpc。