@itpay/cli 2.1.8 → 2.1.10

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.
@@ -203,6 +203,7 @@ function buildBuyEnvelope(input) {
203
203
  const presentationHandoff = buildCheckoutHandoff({
204
204
  platform,
205
205
  url: input.plan.linkOnlyURL ?? input.checkoutURL,
206
+ mobileUrl: input.checkoutURL,
206
207
  amount,
207
208
  plan: input.plan,
208
209
  ...(input.agentType ? { agentType: input.agentType } : {}),
@@ -22,7 +22,7 @@ export async function runCheckoutPresentation(backend, options) {
22
22
  });
23
23
  return;
24
24
  }
25
- const checkoutURL = checkoutPageURL(options.baseURL, options.checkoutID, options.displayToken);
25
+ const checkoutURL = savedCheckoutURLOrFallback(options.savedCheckoutURL, options.checkoutID, options.displayToken, checkoutPageURL(options.baseURL, options.checkoutID, options.displayToken));
26
26
  const cardURL = localizeCardURL(absolutePublicURL(options.baseURL, presentation.card_url ?? checkoutCardURL(options.baseURL, options.checkoutID, options.displayToken)), locale);
27
27
  const qrPNGURL = absolutePublicURL(options.baseURL, presentation.card_png_url ?? presentation.qr_png_url ?? checkoutCardPNGURL(options.baseURL, options.checkoutID, options.displayToken));
28
28
  const localizedPNGURL = localizeCardURL(qrPNGURL, locale);
@@ -68,6 +68,7 @@ function pendingCheckoutEnvelope(presentation, checkoutURL, plan, nextCommand, a
68
68
  const presentationHandoff = buildCheckoutHandoff({
69
69
  platform,
70
70
  url: plan.linkOnlyURL ?? checkoutURL,
71
+ mobileUrl: checkoutURL,
71
72
  amount,
72
73
  plan,
73
74
  ...(agentType ? { agentType } : {}),
@@ -164,6 +165,20 @@ function checkoutPageURL(baseURL, checkoutID, displayToken) {
164
165
  const root = publicRoot(baseURL);
165
166
  return `${root}/checkout/${encodeURIComponent(checkoutID)}?display_token=${encodeURIComponent(displayToken)}`;
166
167
  }
168
+ function savedCheckoutURLOrFallback(savedURL, checkoutID, displayToken, fallback) {
169
+ if (!savedURL) {
170
+ return fallback;
171
+ }
172
+ try {
173
+ const parsed = new URL(savedURL);
174
+ const belongsToCheckout = parsed.pathname === `/checkout/${checkoutID}`;
175
+ const sameToken = parsed.searchParams.get("display_token") === displayToken;
176
+ return belongsToCheckout && sameToken ? savedURL : fallback;
177
+ }
178
+ catch {
179
+ return fallback;
180
+ }
181
+ }
167
182
  function checkoutCardURL(baseURL, checkoutID, displayToken) {
168
183
  const root = publicRoot(baseURL);
169
184
  return `${root}/v1/checkouts/${encodeURIComponent(checkoutID)}/card?display_token=${encodeURIComponent(displayToken)}`;
@@ -17,8 +17,26 @@ export function buildWorkBuddyPresentFilesAction(url) {
17
17
  arguments: { files: [url] },
18
18
  };
19
19
  }
20
+ function hasCompleteCheckoutTokens(url) {
21
+ if (!url) {
22
+ return false;
23
+ }
24
+ try {
25
+ const parsed = new URL(url);
26
+ return Boolean(parsed.searchParams.get("display_token") && parsed.searchParams.get("complete_exchange_token"));
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
20
32
  export function buildCheckoutHandoff(input) {
21
33
  const handoff = { url: input.url };
34
+ // mobile_url is only emitted when the link carries the complete token pair;
35
+ // a display-token-only link cannot finish the same-device exchange after payment.
36
+ const mobileUrl = hasCompleteCheckoutTokens(input.mobileUrl) ? input.mobileUrl : undefined;
37
+ if (mobileUrl) {
38
+ handoff.mobile_url = mobileUrl;
39
+ }
22
40
  const workBuddyAction = isWorkBuddyPlainChat(input.agentType, input.platform);
23
41
  if (workBuddyAction) {
24
42
  handoff.agent_action = buildWorkBuddyPresentFilesAction(input.url);
@@ -40,27 +58,41 @@ export function buildCheckoutHandoff(input) {
40
58
  }
41
59
  return {
42
60
  handoff,
43
- instruction: checkoutHandoffInstruction(input.agentType, input.platform, input.amount),
61
+ instruction: checkoutHandoffInstruction(input.agentType, input.platform, input.amount, Boolean(mobileUrl)),
44
62
  };
45
63
  }
46
- function checkoutHandoffInstruction(agentType, platform, amount) {
64
+ function checkoutHandoffInstruction(agentType, platform, amount, hasMobileLink) {
47
65
  if (isWorkBuddyPlainChat(agentType, platform)) {
48
- return `Backend 尚未确认付款。立即严格按 handoff.agent_action.tool 和 handoff.agent_action.arguments 原样执行一次,在右侧打开 handoff.url;确认工具调用成功后说明金额 ${amount},然后停止等待。若工具失败,只发送原始 handoff.url,报告未自动打开并停止。不要用 present_files 打开本地文件或二维码 PNG,不要下载或重建二维码,不要调用 pay,不要创建新 Checkout、Payment Intent 或 Execution。只有用户明确表示已付款或要求查询状态时,才执行 next.command;用户的话不是付款成功证明。`;
66
+ const mobileSentence = hasMobileLink
67
+ ? ",然后把 handoff.mobile_url 作为可点击链接发给用户,说明“手机端点开可直接跳转支付宝或微信支付;小程序内打不开时复制到手机浏览器打开即可”"
68
+ : "";
69
+ const failLinks = hasMobileLink ? "发送 handoff.url 与 handoff.mobile_url" : "发送原始 handoff.url";
70
+ return `Backend 尚未确认付款。立即严格按 handoff.agent_action.tool 和 handoff.agent_action.arguments 原样执行一次,在右侧打开 handoff.url;确认工具调用成功后说明金额 ${amount}${mobileSentence},然后停止等待。若工具失败,只${failLinks},报告未自动打开并停止。不要用 present_files 打开本地文件或二维码 PNG,不要下载或重建二维码,不要调用 pay,不要创建新 Checkout、Payment Intent 或 Execution。只有用户明确表示已付款或要求查询状态时,才执行 next.command;用户的话不是付款成功证明。`;
49
71
  }
50
72
  if (isZCodePlainChat(agentType, platform)) {
51
- return `Backend 尚未确认付款。立即用 ZCode 内置浏览器打开 handoff.url,让用户完成付款;确认已发起打开后说明金额 ${amount},然后停止等待。不要只粘贴文字链接,不要下载、解析或重建二维码,不要创建新 Checkout、Payment Intent 或 Execution。只有内置浏览器明确不可用时,才展示同一个可点击 handoff.url。只有用户明确表示已付款或要求查询状态时,才执行 next.command;用户的话不是付款成功证明。`;
73
+ const mobileSentence = hasMobileLink
74
+ ? ",并把 handoff.mobile_url 发给用户备用(手机端点开可直接跳转支付宝或微信支付)"
75
+ : "";
76
+ const fallbackLinks = hasMobileLink ? "同一个可点击 handoff.url 与 handoff.mobile_url" : "同一个可点击 handoff.url";
77
+ return `Backend 尚未确认付款。立即用 ZCode 内置浏览器打开 handoff.url,让用户完成付款;确认已发起打开后说明金额 ${amount}${mobileSentence},然后停止等待。不要只粘贴文字链接,不要下载、解析或重建二维码,不要创建新 Checkout、Payment Intent 或 Execution。只有内置浏览器明确不可用时,才展示${fallbackLinks}。只有用户明确表示已付款或要求查询状态时,才执行 next.command;用户的话不是付款成功证明。`;
52
78
  }
53
79
  if (agentType?.trim().toLowerCase() === "doubao-work" && platform === "plain_chat") {
54
- return `Backend 尚未确认付款。在当前豆包工作对话中展示两个有标签的官方入口:将 handoff.url 标为“手机直接打开收银台”,将 handoff.qr_image_url 标为“二维码图片(保存或用另一台设备扫码)”。说明金额 ${amount} 后停止等待。不要解析或单独输出 URL credential,不要下载或重建二维码,不要调用 pay,不要创建新 Checkout、Payment Intent 或 Execution。只有用户明确表示已付款或要求查询状态时,才执行 next.command;用户的话不是付款成功证明。`;
80
+ const directLink = hasMobileLink ? "handoff.mobile_url" : "handoff.url";
81
+ const directLabel = hasMobileLink ? "手机直接打开收银台(可跳转支付宝/微信)" : "手机直接打开收银台";
82
+ return `Backend 尚未确认付款。在当前豆包工作对话中展示两个有标签的官方入口:将 ${directLink} 标为“${directLabel}”,将 handoff.qr_image_url 标为“二维码图片(保存或用另一台设备扫码)”。说明金额 ${amount} 后停止等待。不要解析或单独输出 URL credential,不要下载或重建二维码,不要调用 pay,不要创建新 Checkout、Payment Intent 或 Execution。只有用户明确表示已付款或要求查询状态时,才执行 next.command;用户的话不是付款成功证明。`;
55
83
  }
56
84
  if (platform === "markdown") {
57
- return `Backend 尚未确认付款。把 handoff.markdown 原样发送到当前桌面对话,确认二维码、付款链接和金额 ${amount} 均已实际对用户可见,然后停止等待。不要创建新 Checkout、Payment Intent 或 Execution;只有用户明确表示已付款或要求查询状态时,才执行 next.command;用户的话不是付款成功证明。`;
85
+ const mobileSentence = hasMobileLink ? ",并附 handoff.mobile_url 供手机端点击直达支付" : "";
86
+ return `Backend 尚未确认付款。把 handoff.markdown 原样发送到当前桌面对话,确认二维码、付款链接和金额 ${amount} 均已实际对用户可见${mobileSentence},然后停止等待。不要创建新 Checkout、Payment Intent 或 Execution;只有用户明确表示已付款或要求查询状态时,才执行 next.command;用户的话不是付款成功证明。`;
58
87
  }
59
88
  if (platform === "terminal") {
60
- return `Backend 尚未确认付款。在用户可见终端展示当前 Checkout 的二维码、handoff.url 和金额 ${amount},然后停止等待。不要创建新 Checkout、Payment Intent 或 Execution;只有用户明确表示已付款或要求查询状态时,才执行 next.command;用户的话不是付款成功证明。`;
89
+ const mobileSentence = hasMobileLink ? ",并展示 handoff.mobile_url 供手机端点击直达支付" : "";
90
+ return `Backend 尚未确认付款。在用户可见终端展示当前 Checkout 的二维码、handoff.url 和金额 ${amount}${mobileSentence},然后停止等待。不要创建新 Checkout、Payment Intent 或 Execution;只有用户明确表示已付款或要求查询状态时,才执行 next.command;用户的话不是付款成功证明。`;
61
91
  }
62
92
  if (agentType?.trim().toLowerCase() === "openclaw" && platform === "telegram") {
63
- return `Backend 尚未确认付款或授权状态。立即使用 OpenClaw 原生 message tool,严格按 handoff.agent_action.tool 和 handoff.agent_action.arguments 原样执行;它会发送金额 ${amount}、二维码、📱 手机点这儿支付和 📋 已授权给我读。不得改写 presentation、换用其他消息工具、拆开按钮发送或用普通回复声称按钮已经发送。确认 message tool 调用成功后停止等待。收到 callback_data "itp:grant_confirmed:<checkout_id>" 时,表示用户声明已在收银台授权读取;立即执行 next.command 查询同一 Checkout,再只执行 Backend 返回的 next.command 进入同一 Execution 的 grant 流程。该 callback 不证明付款成功或 grant 已生效;Backend 未返回 grant_active 前不得读取或猜测结果。若原生 message tool 明确失败或当前 Telegram 未启用 inline buttons,只发送现有 handoff.qr_image_url、金额和 handoff.url,报告按钮不可用并停止;不要创建新的 Checkout、Payment Intent 或 Execution。`;
93
+ const failLinks = hasMobileLink ? "handoff.qr_image_url、金额、handoff.url 与 handoff.mobile_url" : "handoff.qr_image_url、金额和 handoff.url";
94
+ return `Backend 尚未确认付款或授权状态。立即使用 OpenClaw 原生 message tool,严格按 handoff.agent_action.tool 和 handoff.agent_action.arguments 原样执行;它会发送金额 ${amount}、二维码、📱 手机点这儿支付和 📋 已授权给我读。不得改写 presentation、换用其他消息工具、拆开按钮发送或用普通回复声称按钮已经发送。确认 message tool 调用成功后停止等待。收到 callback_data "itp:grant_confirmed:<checkout_id>" 时,表示用户声明已在收银台授权读取;立即执行 next.command 查询同一 Checkout,再只执行 Backend 返回的 next.command 进入同一 Execution 的 grant 流程。该 callback 不证明付款成功或 grant 已生效;Backend 未返回 grant_active 前不得读取或猜测结果。若原生 message tool 明确失败或当前 Telegram 未启用 inline buttons,只发送现有 ${failLinks},报告按钮不可用并停止;不要创建新的 Checkout、Payment Intent 或 Execution。`;
64
95
  }
65
- return `Backend 尚未确认付款。把 handoff.url 和可用的 handoff.qr_image_url 实际发送到当前会话,说明金额 ${amount},然后停止等待。不要创建新 Checkout、Payment Intent 或 Execution;只有用户明确表示已付款或要求查询状态时,才执行 next.command;用户的话不是付款成功证明。`;
96
+ const urlList = hasMobileLink ? "handoff.url、handoff.mobile_url(手机端点击直达支付)和可用的 handoff.qr_image_url" : "handoff.url 和可用的 handoff.qr_image_url";
97
+ return `Backend 尚未确认付款。把 ${urlList} 实际发送到当前会话,说明金额 ${amount},然后停止等待。不要创建新 Checkout、Payment Intent 或 Execution;只有用户明确表示已付款或要求查询状态时,才执行 next.command;用户的话不是付款成功证明。`;
66
98
  }
@@ -1095,6 +1095,17 @@ function servicesNextEnvelope(model) {
1095
1095
  recovery: [],
1096
1096
  };
1097
1097
  }
1098
+ const review = action.context?.review;
1099
+ if (review) {
1100
+ return {
1101
+ status: "booking_review_required",
1102
+ result: { service_execution_id: id, service_id: execution.service_id, human_action: action,
1103
+ required_fields: requiredFields },
1104
+ instruction: "向用户展示 context.review 中的行程(legs)与可选席别/座位偏好词表(seat_options),并明确告知:座位偏好仅为请求、不保证分配(对应 notice_version 文案须经用户同意)。收集乘客人数、席别与每位乘客偏好后,按 required_fields 组装完整 JSON 对象,经 --input-json <file> 提交——draft_revision 必须等于服务端当前值,被拒绝(booking_review_changed)时重新读取后重试。乘客姓名/证件等身份信息不在此提交,仍走受保护 Checkout 页。",
1105
+ next: { command: `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved --input-json <file> --json`, reason: "提交用户确认后的行程确认/修订" },
1106
+ recovery: [{ command: `itpay services next ${id} --json`, reason: "重新读取当前 draft_revision 后重试" }],
1107
+ };
1108
+ }
1098
1109
  return {
1099
1110
  status: "confirmation_required",
1100
1111
  result: { service_execution_id: id, service_id: execution.service_id, human_action: action },
@@ -1139,7 +1150,9 @@ function servicesNextEnvelope(model) {
1139
1150
  },
1140
1151
  instruction: failedInstruction
1141
1152
  ?? (state === "payment"
1142
- ? "服务已到付款步骤,使用现有 Checkout 完成扫码付款。"
1153
+ ? (model.workflow?.human_action?.context?.review
1154
+ ? "服务已到付款步骤,可直接 Checkout 付款;如需调整席别/座位偏好,先用 confirm_booking 动作修订(会重新报价并锁定新价),完成后再付款。"
1155
+ : "服务已到付款步骤,使用现有 Checkout 完成扫码付款。")
1143
1156
  : guidance
1144
1157
  ? "按 result.guidance 的字段契约填写输入后继续同一服务执行;不要臆造字段名。"
1145
1158
  : "继续读取同一执行;缺少输入时按服务声明补齐。"),
@@ -1149,7 +1162,9 @@ function servicesNextEnvelope(model) {
1149
1162
  { command: `itpay services start ${execution.service_id} --json`, reason: "修正输入后重新发起(本执行已终止不能续用)" },
1150
1163
  ...(guidance ? [{ command: "itpay docs show rail-booking --json", reason: "查看本服务输入字段契约与示例" }] : []),
1151
1164
  ]
1152
- : [],
1165
+ : state === "payment" && model.workflow?.human_action?.context?.review
1166
+ ? [{ command: `itpay services action ${id} --action ${model.workflow.human_action.action_type} --actor-type human --status approved --input-json <file> --json`, reason: "付款前修订席别/座位偏好(draft_revision 以服务端为准)" }]
1167
+ : [],
1153
1168
  };
1154
1169
  }
1155
1170
  const terminalEnvelope = terminalExecutionEnvelope(model);
@@ -1485,6 +1500,7 @@ function buildServicesCheckoutEnvelope(response, checkoutURL, plan, agentType, t
1485
1500
  const presentationHandoff = buildCheckoutHandoff({
1486
1501
  platform,
1487
1502
  url: plan.linkOnlyURL ?? checkoutURL,
1503
+ mobileUrl: checkoutURL,
1488
1504
  amount,
1489
1505
  plan,
1490
1506
  ...(agentType ? { agentType } : {}),
package/dist/src/main.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { agentAuth } from "./state/account_auth.js";
2
2
  import { registerSell } from "./sell/commands.js";
3
- import { readFileSync as readWorkflowInputFile } from "node:fs";
3
+ import { readFileSync as readWorkflowInputFile, statSync as statWorkflowInputFile } from "node:fs";
4
4
  import { runServicesRun } from "./commands/services.js";
5
5
  // V3 CLI entrypoint. Each command maps 1:1 to a route family in
6
6
  // services/backend/internal/httpapi/handlers/*.go. Commands only
@@ -137,10 +137,19 @@ function reportCLIError(error, contract) {
137
137
  const providerInputRejected = error instanceof HttpError && error.code === "provider_input_rejected";
138
138
  const providerContractMismatch = error instanceof HttpError && error.code === "provider_contract_mismatch";
139
139
  const capabilityInputInvalid = error instanceof HttpError && error.code === "capability_input_invalid";
140
- const deviceRecovery = deviceError ? [{
140
+ const deviceKeyResettable = Boolean(deviceError && (deviceError.code === "agent_device_key_rotated" ||
141
+ deviceError.code === "agent_device_key_conflict" ||
142
+ (deviceError.enrollmentFailed && deviceError.status === 500)));
143
+ const deviceRecovery = deviceError ? [
144
+ {
141
145
  command: "itpay skill show itpay --json",
142
146
  reason: "读取 ItPay 身份边界;该错误需要用户或运营恢复 Backend 登记,不能通过换类型或删除本地身份绕过",
143
- }] : [];
147
+ },
148
+ ...(deviceKeyResettable ? [{
149
+ command: "itpay device reset-key --confirm-key-reset --json",
150
+ reason: "服务端拒绝以当前私钥完成设备登记;生成全新 Ed25519 密钥并重新登记(旧设备身份在服务端保留为孤儿,不影响新身份和额度谱系)",
151
+ }] : []),
152
+ ] : [];
144
153
  const stateRecovery = stateError ? [{
145
154
  command: "itpay skill show itpay --json",
146
155
  reason: "读取 Device 状态边界;修复当前 Host 的持久写权限后重试原命令",
@@ -151,9 +160,13 @@ function reportCLIError(error, contract) {
151
160
  ? "CLI 已自动续期并重试同一请求一次,仍被拒绝;停止重试,不要切换 Agent Type 或旋转身份。"
152
161
  : deviceError?.code === "agent_device_revoked"
153
162
  ? "Backend 已撤销当前 Device 登记;CLI 没有自动创建替代身份。停止重试并请用户或运营恢复登记。"
154
- : deviceError
155
- ? "Device 身份验证失败;停止重试,不要切换 Agent Type、删除状态或旋转私钥。"
156
- : undefined;
163
+ : deviceKeyResettable && deviceError?.status === 500
164
+ ? "服务端未能完成设备登记。先原样重试一次原命令:服务端会把已登记的同一公钥幂等挂回原设备。若仍返回 internal_error(Backend 未含该修复),执行 itpay device reset-key --confirm-key-reset 生成全新密钥后重试。"
165
+ : deviceKeyResettable
166
+ ? "服务端记录显示当前设备私钥已不再有效(已轮换或与既有登记冲突)。执行 itpay device reset-key --confirm-key-reset 生成全新密钥并重新登记。"
167
+ : deviceError
168
+ ? "Device 身份验证失败;停止重试,不要切换 Agent Type、删除状态或旋转私钥。"
169
+ : undefined;
157
170
  if (contract || commandError || backendOverrideError) {
158
171
  writeCommandEnvelope({
159
172
  status: "error",
@@ -359,6 +372,53 @@ deviceCmd
359
372
  });
360
373
  }
361
374
  });
375
+ deviceCmd
376
+ .command("reset-key")
377
+ .description("Discard the local Ed25519 device key and all Backend registrations so the next command enrolls as a new device")
378
+ .option("--confirm-key-reset", "confirm that the current device key must be abandoned and a new identity created")
379
+ .option("--json", "output JSON instead of terminal text")
380
+ .action(async (options) => {
381
+ const config = loadConfig();
382
+ try {
383
+ if (!options.confirmKeyReset) {
384
+ throw new CommandContractError("key_reset_confirmation_required", "--confirm-key-reset is required", "仅当服务端拒绝以当前私钥完成设备登记(internal_error、agent_device_key_rotated 或 agent_device_key_conflict)时使用;会放弃本地设备身份并重新登记,旧设备在服务端保留为孤儿。", [{ command: "itpay docs show identity-and-sessions --json", reason: "检查适用边界" }]);
385
+ }
386
+ const reset = await new DeviceAuthority({
387
+ baseURL: config.baseURL,
388
+ ...(config.agentType ? { requestedAgentType: config.agentType } : {}),
389
+ compatibilityHeaders: {},
390
+ }).resetDeviceKey();
391
+ writeCommandEnvelope({
392
+ status: "device_key_reset",
393
+ result: {
394
+ removed_backend_registrations: reset.removedBackends,
395
+ private_key_preserved: false,
396
+ server_side_device: "orphaned_under_previous_key",
397
+ },
398
+ instruction: "本地设备私钥已重置;下一次需要设备身份的命令会以全新 Ed25519 密钥重新登记并获得新的额度谱系。服务端旧设备记录保留为孤儿;如需清理请走运营流程。",
399
+ next: {
400
+ command: `itpay --agent-type ${config.agentType ?? "<agent_type>"} services list --limit 1 --json`,
401
+ reason: "用无业务写入的签名请求完成新密钥的重新登记",
402
+ },
403
+ recovery: [],
404
+ }, {
405
+ jsonOutput: Boolean(options.json),
406
+ plainResult: [
407
+ `removed_backend_registrations: ${reset.removedBackends.join(", ") || "none"}`,
408
+ "private_key: rotated (previous key discarded)",
409
+ "server_side_device: orphaned under previous key",
410
+ ],
411
+ });
412
+ }
413
+ catch (error) {
414
+ reportCLIError(error, {
415
+ jsonOutput: Boolean(options.json),
416
+ code: "device_key_reset_failed",
417
+ instruction: "仅在确认要放弃当前设备私钥时使用;普通 session 失效或 revoked 不需要本命令。",
418
+ recovery: [{ command: "itpay docs show identity-and-sessions --json", reason: "检查 Device 恢复边界" }],
419
+ });
420
+ }
421
+ });
362
422
  // --- skill ----------------------------------------------------------------
363
423
  const skillCmd = program.command("skill").description("Read complete packaged Agent skills");
364
424
  skillCmd
@@ -850,6 +910,9 @@ program
850
910
  await runCheckoutPresentation(backend, {
851
911
  checkoutID,
852
912
  displayToken,
913
+ ...(snap.lastCheckoutID === checkoutID && snap.lastCheckoutURL
914
+ ? { savedCheckoutURL: snap.lastCheckoutURL }
915
+ : {}),
853
916
  host,
854
917
  ...(options.target ? { target: options.target } : {}),
855
918
  ...(config.agentType ? { agentType: config.agentType } : {}),
@@ -1231,6 +1294,25 @@ vault
1231
1294
  }
1232
1295
  });
1233
1296
  // --- service execution ----------------------------------------------------
1297
+ const WORKFLOW_INPUT_JSON_MAX_BYTES = 256 * 1024;
1298
+ function readInputJsonObject(file, code, recovery) {
1299
+ let parsed;
1300
+ try {
1301
+ if (statWorkflowInputFile(file).size > WORKFLOW_INPUT_JSON_MAX_BYTES) {
1302
+ throw new CommandContractError(code, `--input-json file exceeds ${WORKFLOW_INPUT_JSON_MAX_BYTES} bytes`, "输入文件过大;只提交服务端 input_schema 声明的字段。", recovery);
1303
+ }
1304
+ parsed = JSON.parse(readWorkflowInputFile(file, "utf8"));
1305
+ }
1306
+ catch (error) {
1307
+ if (error instanceof CommandContractError)
1308
+ throw error;
1309
+ throw new CommandContractError(code, `--input-json is not readable valid JSON: ${error instanceof Error ? error.message : String(error)}`, "确认文件存在且为合法 JSON;按服务端 input_schema 字段填写。", recovery);
1310
+ }
1311
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1312
+ throw new CommandContractError(code, "--input-json must contain a JSON object", "确认输入必须是 JSON 对象;按服务端 input_schema 字段填写。", recovery);
1313
+ }
1314
+ return parsed;
1315
+ }
1234
1316
  const services = program.command("services").description("Generic V3 Service Execution commands");
1235
1317
  services
1236
1318
  .command("run")
@@ -1245,10 +1327,11 @@ services
1245
1327
  .action(async (serviceID, options) => {
1246
1328
  const config = loadConfig();
1247
1329
  try {
1248
- const input = options.inputJson ? JSON.parse(readWorkflowInputFile(options.inputJson, "utf8")) : undefined;
1249
- if (input !== undefined && (!input || typeof input !== "object" || Array.isArray(input))) {
1250
- throw new Error("input JSON must be an object");
1251
- }
1330
+ const input = options.inputJson
1331
+ ? readInputJsonObject(options.inputJson, "workflow_input_invalid", [
1332
+ { command: `itpay services run ${serviceID} --json`, reason: "重新读取服务输入声明" },
1333
+ ])
1334
+ : undefined;
1252
1335
  if (!Number.isFinite(options.timeout) || options.timeout < 0 || options.timeout > 600) {
1253
1336
  throw new Error("timeout must be between 0 and 600 seconds");
1254
1337
  }
@@ -1362,12 +1445,18 @@ services
1362
1445
  .option("--result-item <service_capability_result_item_id>")
1363
1446
  .option("--required-before <step>")
1364
1447
  .option("--input <key=value>", "action input snapshot", collectOption, [])
1448
+ .option("--input-json <file>", "JSON object containing the full action input snapshot")
1365
1449
  .option("--json", "output JSON instead of terminal text")
1366
1450
  .action(async (serviceExecutionID, options) => {
1367
1451
  const config = loadConfig();
1368
1452
  const backend = newBackendClient(config);
1369
1453
  try {
1370
- await runServicesAction(backend, serviceExecutionID, options.action, parseKeyValueList(options.input), {
1454
+ const recovery = [{ command: `itpay services next ${serviceExecutionID} --json`, reason: "读取当前动作要求" }];
1455
+ if (options.inputJson && options.input.length > 0) {
1456
+ throw new CommandContractError("service_action_invalid", "--input and --input-json cannot be combined", "--input 与 --input-json 只能二选一:嵌套结构用 --input-json <file>,扁平键值用 --input key=value。", recovery);
1457
+ }
1458
+ const fileInput = options.inputJson ? readInputJsonObject(options.inputJson, "service_action_invalid", recovery) : {};
1459
+ await runServicesAction(backend, serviceExecutionID, options.action, { ...fileInput, ...parseKeyValueList(options.input) }, {
1371
1460
  ...(options.actorType ? { actorType: options.actorType } : {}),
1372
1461
  ...(options.actorId ? { actorID: options.actorId } : {}),
1373
1462
  ...(options.status ? { status: options.status } : {}),
@@ -15,7 +15,7 @@ import { OperationJournal } from "./operation_journal.js";
15
15
  export const DEFAULT_BASE_URL = "https://app.itpay.ai";
16
16
  export const DEV_BASE_URL = "https://dev.itpay.ai";
17
17
  export const SANDBOX_BASE_URL = "https://sandbox.itpay.ai";
18
- export const CLI_VERSION = "2.1.8";
18
+ export const CLI_VERSION = "2.1.10";
19
19
  export const API_CONTRACT_REVISION = "sha256:66a95bbc5ec9d7b35747cf87206c63dd45983f26de21afab582d8dd63bf198f7";
20
20
  const CART_SESSION_DEFAULT_DIR = ".itpay-v3";
21
21
  const CART_SESSION_FILENAME = "cart.json";
@@ -73,6 +73,27 @@ export class DeviceAuthority {
73
73
  return { removed: true, agentTypes };
74
74
  });
75
75
  }
76
+ async resetDeviceKey() {
77
+ return withFileLock(`${this.statePath}.lock`, async () => {
78
+ const state = this.readState();
79
+ const removedBackends = state ? Object.keys(state.registrations).sort() : [];
80
+ // Delete the key first: if the process dies before the state write, the
81
+ // next run treats the missing key as a fresh install and completes the
82
+ // reset itself; it can never pair a new key with stale registrations.
83
+ if (existsSync(this.privateKeyPath)) {
84
+ try {
85
+ unlinkSync(this.privateKeyPath);
86
+ }
87
+ catch (error) {
88
+ if (error.code !== "ENOENT") {
89
+ throw asDeviceStateError(error, "remove_private_key") ?? error;
90
+ }
91
+ }
92
+ }
93
+ this.writeState(emptyDeviceState());
94
+ return { removedBackends };
95
+ });
96
+ }
76
97
  async prepareAuthorization() {
77
98
  let state = this.readState() ?? emptyDeviceState();
78
99
  const agentType = this.requestedAgentType;
@@ -83,8 +104,14 @@ export class DeviceAuthority {
83
104
  if (!privateKey) {
84
105
  const pair = generateKeyPairSync("ed25519");
85
106
  privateKey = pair.privateKey;
86
- this.writePrivateKey(pair.privateKey.export({ format: "pem", type: "pkcs8" }).toString());
107
+ // Persist an empty state before the new key ever reaches disk so a crash
108
+ // can never pair the new key with registrations bound to the old one.
109
+ // A crash between the two writes leaves either no key (fresh retry) or a
110
+ // key with no registrations (the backend re-attaches to the existing
111
+ // device instead of creating a duplicate).
87
112
  state = emptyDeviceState();
113
+ this.writeState(state);
114
+ this.writePrivateKey(pair.privateKey.export({ format: "pem", type: "pkcs8" }).toString());
88
115
  }
89
116
  let registration = state.registrations[this.backendKey];
90
117
  if (!registration && state.legacyRegistration) {
@@ -102,7 +129,21 @@ export class DeviceAuthority {
102
129
  registration = await this.enroll(agentType, privateKey);
103
130
  }
104
131
  state.registrations[this.backendKey] = registration;
105
- const session = await this.ensureRegistrationAgentType(registration, agentType, privateKey, false);
132
+ let session;
133
+ try {
134
+ session = await this.ensureRegistrationAgentType(registration, agentType, privateKey, false);
135
+ }
136
+ catch (error) {
137
+ if (!isUnknownDeviceRegistration(error))
138
+ throw error;
139
+ // The backend lost this registration (e.g. its identity store was
140
+ // rebuilt). Drop the stale record and enroll once: the same key either
141
+ // re-attaches to the surviving device or creates a fresh one.
142
+ delete state.registrations[this.backendKey];
143
+ registration = await this.enroll(agentType, privateKey);
144
+ state.registrations[this.backendKey] = registration;
145
+ session = await this.ensureRegistrationAgentType(registration, agentType, privateKey, false);
146
+ }
106
147
  this.writeState(state);
107
148
  return { state: registration, agentType, session, privateKey };
108
149
  }
@@ -136,16 +177,23 @@ export class DeviceAuthority {
136
177
  if (!publicJWK.x)
137
178
  throw new Error("unable to export Ed25519 public key");
138
179
  const publicKey = Buffer.from(publicJWK.x, "base64url").toString("base64");
139
- const started = await this.publicJSON("/v1/agent-device-enrollments", { public_key: publicKey, agent_type: agentType });
140
- const proof = enrollmentProofMessage(started.agent_device_enrollment_id, started.challenge);
141
- const verified = await this.publicJSON(`/v1/agent-device-enrollments/${encodeURIComponent(started.agent_device_enrollment_id)}/verify`, { challenge: started.challenge, signature: sign(null, Buffer.from(proof), privateKey).toString("base64") });
142
- return {
143
- deviceID: verified.agent_device_id,
144
- deviceKeyID: verified.agent_device_key_id,
145
- quotaLineageID: verified.quota_lineage_id,
146
- agentInstances: { [verified.agent_type]: verified.agent_instance_id },
147
- sessions: {},
148
- };
180
+ try {
181
+ const started = await this.publicJSON("/v1/agent-device-enrollments", { public_key: publicKey, agent_type: agentType });
182
+ const proof = enrollmentProofMessage(started.agent_device_enrollment_id, started.challenge);
183
+ const verified = await this.publicJSON(`/v1/agent-device-enrollments/${encodeURIComponent(started.agent_device_enrollment_id)}/verify`, { challenge: started.challenge, signature: sign(null, Buffer.from(proof), privateKey).toString("base64") });
184
+ return {
185
+ deviceID: verified.agent_device_id,
186
+ deviceKeyID: verified.agent_device_key_id,
187
+ quotaLineageID: verified.quota_lineage_id,
188
+ agentInstances: { [verified.agent_type]: verified.agent_instance_id },
189
+ sessions: {},
190
+ };
191
+ }
192
+ catch (error) {
193
+ if (error instanceof DeviceAuthorizationError)
194
+ error.enrollmentFailed = true;
195
+ throw error;
196
+ }
149
197
  }
150
198
  async ensureSession(state, agentType, privateKey, force = false) {
151
199
  const existing = state.sessions[agentType];
@@ -233,6 +281,7 @@ export class DeviceAuthority {
233
281
  export class DeviceAuthorizationError extends Error {
234
282
  status;
235
283
  code;
284
+ enrollmentFailed = false;
236
285
  constructor(status, code, message) {
237
286
  super(message);
238
287
  this.status = status;
@@ -257,6 +306,9 @@ function emptyDeviceState() {
257
306
  function canMovePastLegacyRegistration(error) {
258
307
  return error instanceof DeviceAuthorizationError && (error.code === "agent_device_revoked" || error.status === 404);
259
308
  }
309
+ function isUnknownDeviceRegistration(error) {
310
+ return error instanceof DeviceAuthorizationError && (error.code === "agent_device_not_found" || error.status === 404);
311
+ }
260
312
  function normalizeBackendKey(value) {
261
313
  const url = new URL(value);
262
314
  url.search = "";
@@ -7,7 +7,7 @@
7
7
  "purpose": "Explain stable local identity, official Backend-scoped registrations, Agent Type instances, and bounded automatic session recovery.",
8
8
  "when_to_use": [
9
9
  "A different Agent window or runtime starts using an existing CLI installation.",
10
- "The CLI reports device_state_unwritable, or the Backend reports agent_identity_required, agent_device_session_required, or agent_device_revoked.",
10
+ "The CLI reports device_state_unwritable, or the Backend reports agent_identity_required, agent_device_session_required, agent_device_not_found, or agent_device_revoked.",
11
11
  "An operator confirms that the currently selected official Backend registration database was reset."
12
12
  ],
13
13
  "identity_model": {
@@ -21,7 +21,9 @@
21
21
  "On one HTTP 401 with code agent_device_session_required, it invalidates only the current Backend and Agent Type session, renews it, and retries the exact request once.",
22
22
  "A second failure is returned to the Agent. The CLI does not loop, switch Agent Type, rotate the key, or create a replacement v2 device.",
23
23
  "When a previously unseen Agent Type is registered under an existing Device, the CLI skips locally known Agent Instances that the Backend reports as revoked and uses the first remaining valid Instance. It never reactivates a revoked Instance or replaces the Device; if none is valid, registration stops.",
24
- "A revoked v2 registration requires explicit operator recovery; it is never silently replaced."
24
+ "When the Backend reports agent_device_not_found (HTTP 404) for a locally known registration — for example after the Backend registration database was rebuilt — the CLI drops only that Backend's registration and re-enrolls once with the same private key, re-attaching to the same device and quota lineage where the Backend still holds the key.",
25
+ "A revoked v2 registration requires explicit operator recovery; it is never silently replaced.",
26
+ "Re-enrolling a public key the Backend already holds re-attaches to the existing device and quota lineage after proof-of-possession; it never creates a duplicate device. If enrollment verification keeps failing with internal_error, or returns agent_device_key_rotated or agent_device_key_conflict, `itpay device reset-key --confirm-key-reset` is the supported way to start over with a fresh key."
25
27
  ],
26
28
  "local_state_recovery": [
27
29
  "Device state updates use an atomic directory lock and owner-only atomic file replacement; no lock file is opened with fs.open or fs.openSync.",
@@ -44,6 +46,11 @@
44
46
  "intent": "recover after an operator-confirmed reset of the selected official Backend registration database",
45
47
  "command": "itpay --agent-type <agent_type> device recover --confirm-backend-reset --json",
46
48
  "success_signal": "only the selected Backend registration is removed and the private key and other Backend registration remain"
49
+ },
50
+ {
51
+ "intent": "abandon the local device key after the Backend refuses to complete enrollment with it (persistent internal_error, agent_device_key_rotated, or agent_device_key_conflict)",
52
+ "command": "itpay device reset-key --confirm-key-reset --json",
53
+ "success_signal": "the local private key is discarded and the next command enrolls as a brand-new device"
47
54
  }
48
55
  ],
49
56
  "agent_rules": [
@@ -26,7 +26,11 @@
26
26
  "travel_date": "Travel date in YYYY-MM-DD. The field name must be exactly travel_date; aliases like date are rejected."
27
27
  },
28
28
  "optional": "None. additionalProperties is false — extra fields are rejected by the provider.",
29
- "example": {"origin": "古镇", "destination": "广州南", "travel_date": "2026-09-19"}
29
+ "example": {
30
+ "origin": "古镇",
31
+ "destination": "广州南",
32
+ "travel_date": "2026-09-19"
33
+ }
30
34
  },
31
35
  "itpay-rail-smart": {
32
36
  "required": {
@@ -44,7 +48,12 @@
44
48
  "priority": "One of balanced | fastest | cheapest | safest | flexible.",
45
49
  "max_transfers": "Integer 0-2, allowed transfer count."
46
50
  },
47
- "example": {"origin": "中山古镇", "destination": "广州南", "travel_date": "2026-09-19", "priority": "fastest"}
51
+ "example": {
52
+ "origin": "中山古镇",
53
+ "destination": "广州南",
54
+ "travel_date": "2026-09-19",
55
+ "priority": "fastest"
56
+ }
48
57
  },
49
58
  "itpay-rail-booking": {
50
59
  "preferred": {
@@ -78,7 +87,8 @@
78
87
  "services start <service> --json returns input_required plus the guidance block for rail services.",
79
88
  "Submit input with services run <service> --execution <id> --input-json <file> --json on the SAME execution.",
80
89
  "itpay-rail-smart may stop at a location confirmation step; show the candidate places to the buyer, then continue the same execution after the human confirms.",
81
- "After the buyer explicitly picks a train/seat, run itpay-rail-booking with the selection token (preferred) or explicit legs.",
90
+ "After the buyer explicitly picks a train/seat, run itpay-rail-booking with the selection token (preferred) or explicit legs. The execution then pauses at the booking review (workflow:confirm_booking): services next returns booking_review_required with human_action.context.review carrying draft_revision, legs, seat_options and notice_version.",
91
+ "Collect the review input and submit it with services action <id> --action workflow:confirm_booking --actor-type human --status approved --input-json <file> --json. Required fields: draft_revision (exact server value), passengers, seat_type, seat_preferences (one entry per passenger_index 0..n-1, values limited to that leg's seat_options), party_preference, fallback=automatic_assignment, notice_version, accept_non_guaranteed=true. Only after an accepted review does the server quote and offer checkout.",
82
92
  "The booking execution reaches a protected checkout: passenger identity data is entered by the human on that page, never through the CLI. Account authentication may happen inside checkout — do not force a separate auth login first (Path B).",
83
93
  "Payment success does not mean the ticket is issued. Issuance is asynchronous; keep reading the same execution with services next.",
84
94
  "If seat details are still syncing after issuance, advise the buyer to verify the itinerary on 12306 instead of repeating the query."
@@ -89,25 +99,67 @@
89
99
  "search": "Supplier query failed: most commonly a wrong field name (must be travel_date) or a nonexistent station name; recheck input against this doc.",
90
100
  "catalog_or_recommend": "Location resolved but no feasible trains: change the date or stations.",
91
101
  "login_required_is_not_failure": "login_required / rate_limited pause the execution; they are NOT failures and never justify a replacement execution.",
92
- "general_rule": "Never replay a failed execution and never guess different field names; correct the input and start a new execution of the same service."
102
+ "general_rule": "Never replay a failed execution and never guess different field names; correct the input and start a new execution of the same service.",
103
+ "booking_review_changed": "The submitted draft_revision is stale: re-read services next for the current review projection and resubmit the full input; do not bump the revision yourself."
93
104
  },
94
105
  "seat_preferences": {
95
106
  "meaning": "A buyer's seat-letter preference may be planned per leg before payment. The plan is submitted to the supplier when encodable; unsupported combinations fall back to automatic assignment.",
96
107
  "letters": "Evidence-backed letters only: O=ABCDF (second class), M=ACDF (first class), 9=ACF (business class). Other layouts auto-assign.",
97
- "boundary": "Never claim a preference was fulfilled before supplier confirmation; check the issued seat in the order/status output."
108
+ "boundary": "Never claim a preference was fulfilled before supplier confirmation; check the issued seat in the order/status output.",
109
+ "review_contract": "Submitted inside the confirm_booking action via --input-json (nested JSON; --input key=value cannot express it and must not be combined with it). One preference per passenger slot, passenger_index 0-based. draft_revision must equal the server projection; a stale revision returns booking_review_changed — re-read services next and resubmit, never increment locally.",
110
+ "revision": "While payment has not started, the same execution accepts a revised review: resubmit confirm_booking with the new draft_revision; the old unpaid quote/checkout is retired and re-quoted. After a payment attempt the review is closed — never create a replacement purchase."
98
111
  },
99
112
  "agent_rules": [
100
113
  "Pick the service by the buyer's input precision: exact station names → itpay-rail-exact; anything fuzzier → itpay-rail-smart; explicit purchase intent after a selection → itpay-rail-booking.",
101
114
  "Never invent input field names. If the guidance block or this doc does not list a field, do not send it.",
102
115
  "Never ask the buyer for passenger names, ID numbers, or phone numbers in chat; those are collected only on the protected checkout page.",
103
116
  "Never reconstruct provider-shaped legs JSON when a booking_offer.selection_token is available; submit the token plus passengers.",
117
+ "Seat preferences are requests, not guarantees: always repeat the notice that automatic assignment remains possible, and never claim a specific seat before the issued order shows it.",
118
+ "Take draft_revision only from the current services next review projection; on booking_review_changed re-read instead of retrying the stale value.",
104
119
  "login_required and rate_limited are pauses, not failures: resume the same execution, never spawn a replacement.",
105
120
  "Do not repeat or replay a failed execution; correct the input and start a new one.",
106
121
  "Do not claim payment equals ticket issuance; issuance is asynchronous and confirmed only by the issued seat status."
107
122
  ],
108
123
  "human_language": {
109
- "prefer": ["车次", "出发站", "到达站", "出行日期", "座位", "余票", "出票中", "已出票", "登录后继续免费查询"],
110
- "avoid": ["workflow", "execution", "provider", "seat_source", "candidate_id", "token", "quota"]
124
+ "prefer": [
125
+ "车次",
126
+ "出发站",
127
+ "到达站",
128
+ "出行日期",
129
+ "座位",
130
+ "余票",
131
+ "出票中",
132
+ "已出票",
133
+ "登录后继续免费查询"
134
+ ],
135
+ "avoid": [
136
+ "workflow",
137
+ "execution",
138
+ "provider",
139
+ "seat_source",
140
+ "candidate_id",
141
+ "token",
142
+ "quota"
143
+ ]
111
144
  },
112
- "search_terms": ["rail", "railway", "train", "ticket", "火车", "火车票", "高铁", "车票", "车次", "seat", "booking", "rail-booking"]
145
+ "search_terms": [
146
+ "rail",
147
+ "railway",
148
+ "train",
149
+ "ticket",
150
+ "火车",
151
+ "火车票",
152
+ "高铁",
153
+ "车票",
154
+ "车次",
155
+ "seat",
156
+ "booking",
157
+ "rail-booking"
158
+ ],
159
+ "notifications": {
160
+ "channels": "Delivery notices reach the checkout-confirmed contact: email always works; a verified mainland SMS recipient is optional and checkout-scoped. The passenger phone, the login phone and the notification recipient are independent facts.",
161
+ "status_read": "The order read exposes masked per-leg notification entries (keys: issued, details_pending, seat_ready) with channel, status and masked recipient — never raw numbers.",
162
+ "truthfulness": "Provider acceptance is not delivery; an uncertain send outcome is reconciled, not silently retried. A notification failure never changes ticket or refund state; report it as a delivery detail, not an order failure.",
163
+ "unavailable": "If SMS is not configured or a recipient is unverified, email still satisfies the contact policy — never present an unverified phone as enabled."
164
+ }
113
165
  }
@@ -33,17 +33,17 @@
33
33
  {
34
34
  "agent_type": "workbuddy",
35
35
  "default_host": "plain-chat",
36
- "responsibility": "execute handoff.agent_action exactly once to open the fully rendered handoff.url with present_files; show the amount and stop"
36
+ "responsibility": "execute handoff.agent_action exactly once to open the fully rendered handoff.url with present_files, then send handoff.mobile_url as a clickable link that jumps straight into Alipay or WeChat Pay on a phone; show the amount and stop"
37
37
  },
38
38
  {
39
39
  "agent_type": "zcode",
40
40
  "default_host": "plain-chat",
41
- "responsibility": "open handoff.url immediately in the ZCode built-in browser and stop; only show the same clickable URL when the built-in browser is unavailable"
41
+ "responsibility": "open handoff.url immediately in the ZCode built-in browser, also send handoff.mobile_url as the phone-friendly entry, then stop; when the built-in browser is unavailable show both clickable links"
42
42
  },
43
43
  {
44
44
  "agent_type": "doubao-work",
45
45
  "default_host": "plain-chat",
46
- "responsibility": "show handoff.url as the preferred same-phone checkout or authorization link and handoff.qr_image_url as the labeled save-or-scan fallback; state the amount for Checkout and stop"
46
+ "responsibility": "show handoff.mobile_url as the preferred same-phone checkout or authorization link and handoff.qr_image_url as the labeled save-or-scan fallback; state the amount for Checkout and stop"
47
47
  },
48
48
  {
49
49
  "agent_type": "kimi-code",
@@ -73,10 +73,11 @@
73
73
  "agent_rules": [
74
74
  "Agent Type is stable identity; Host is presentation and may be explicitly overridden.",
75
75
  "A local QR path is not visible until a desktop Agent attaches or renders that file on the human-facing surface; WorkBuddy never receives a local QR path.",
76
- "For workbuddy with plain-chat, execute handoff.agent_action exactly once; it calls present_files with handoff.url as the only files element and opens the rendered Card Link in the right-side browser.",
77
- "For zcode with plain-chat, open handoff.url in the built-in browser. Do not only paste text, download or rebuild a QR, or invent a private browser-tool payload; use the same clickable URL only as fallback.",
78
- "For doubao-work with plain-chat, show two labeled official links: handoff.url for direct same-phone opening and handoff.qr_image_url for saving or scanning on another device; do not parse credentials, download or rebuild the QR, or create an alternative resource.",
79
- "If the WorkBuddy action fails, send the original handoff.url, report that it did not auto-open, and stop without creating another payment resource.",
76
+ "For workbuddy with plain-chat, execute handoff.agent_action exactly once; it calls present_files with handoff.url as the only files element and opens the rendered Card Link in the right-side browser, then send handoff.mobile_url so phone users can tap straight into Alipay or WeChat Pay.",
77
+ "For zcode with plain-chat, open handoff.url in the built-in browser and also send handoff.mobile_url as the phone entry; do not only paste text, download or rebuild a QR, or invent a private browser-tool payload; when the built-in browser is unavailable show both clickable links.",
78
+ "For doubao-work with plain-chat, show two labeled official links: handoff.mobile_url for direct same-phone opening and handoff.qr_image_url for saving or scanning on another device; do not parse credentials, download or rebuild the QR, or create an alternative resource.",
79
+ "If the WorkBuddy action fails, send the original handoff.url and handoff.mobile_url, report that it did not auto-open, and stop without creating another payment resource.",
80
+ "handoff.mobile_url is the phone-friendly Checkout URL; when the current client cannot open links in place (for example a mobile mini program), send it and tell the human to copy it into the phone browser where it can jump into Alipay or WeChat Pay. It is only returned when the handoff carries the complete token pair; when absent, handoff.url remains the entry.",
80
81
  "Do not claim the handoff was shown until both a usable payment image or QR and the Checkout URL are visible.",
81
82
  "IM Hosts that require a target must receive --target before Checkout creation. OpenClaw has no default Host; Kimi Code uses terminal.",
82
83
  "OpenClaw Telegram uses flat url/value buttons for deployed-version compatibility. A grant_confirmed callback carries only the Checkout ID, never a display token, and never proves payment or an active grant."
@@ -87,6 +87,7 @@ Terminal and error states:
87
87
  ## Rules
88
88
 
89
89
  - The human's phone number and SMS code are entered only on the official page — never through chat or CLI input. A `buyer_id`, a typed phone string, or a passenger phone is **not** proof of phone verification.
90
+ - Verified email OR verified mainland phone both establish a valid login; the user picks on the page. Phone verification is not a prerequisite for registered free search — a verified email alone is sufficient.
90
91
  - Login never grants Vault content, passenger history, or another Agent's authority. Other devices/platforms enroll and authorize separately.
91
92
  - After `authenticated`, server-side identity is re-read; an anonymous execution blocked at the quota step resumes with `services run <service> --execution <id> --json` — no new execution, no re-asked itinerary.
92
93
  - Refusing or cancelling login deletes nothing: saved results and purchase options remain usable.
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## 范围
6
6
 
7
- `itpay device` 只显示该命令组的帮助并退出,不访问 Backend、不读取或修改身份。当前唯一子命令是 `recover`。
7
+ `itpay device` 只显示该命令组的帮助并退出,不访问 Backend、不读取或修改身份。当前子命令是 `recover`(运营确认 Backend 重置后删除本地登记)和 `reset-key`(服务端拒绝当前私钥登记时放弃本地密钥)。
8
8
 
9
9
  仅在运营明确确认当前 Backend 的 Device 登记数据库已重建或清空后,删除本地该 Backend 的 v2 registration:
10
10
 
@@ -24,6 +24,18 @@ Type 或执行 `device recover`;应保留身份并重试原命令一次,持
24
24
 
25
25
  缺少确认参数返回 `backend_reset_confirmation_required`。普通 session 失效由 CLI 自动续期;revoked、quota、权限或未知 Backend 故障不得使用本命令。所有 Agent Type 使用相同输入和输出合同。
26
26
 
27
+ ## `itpay device reset-key`
28
+
29
+ 仅在服务端拒绝以当前私钥完成设备登记时使用(登记阶段持续返回 `internal_error`,或返回 `agent_device_key_rotated` / `agent_device_key_conflict`)。这是本地操作,不访问 Backend:
30
+
31
+ ```bash
32
+ itpay device reset-key --confirm-key-reset --json
33
+ ```
34
+
35
+ 命令删除本地 Ed25519 私钥并清空所有 Backend 的本地登记记录;下一次需要设备身份的命令会以全新密钥重新登记为新设备。服务端旧设备记录保留为孤儿,不会被删除或复用;原设备的额度谱系不迁移。先尝试普通重试——Backend 会把已验证私钥的重复登记幂等挂回原设备,只有在该修复不可用或私钥已被服务端轮换/冲突时才需要本命令。
36
+
37
+ 缺少确认参数返回 `key_reset_confirmation_required`;本地文件操作失败返回 `device_key_reset_failed`。本命令不得删除 Cart、operation journal 或 `~/.itpay-v3` 的其他内容。
38
+
27
39
  ## 参数
28
40
 
29
41
  | 参数 | 必填 | 说明 |
@@ -16,9 +16,15 @@ itpay services action <service_execution_id> --action <action_type>
16
16
  [--actor-type <actor_type>] [--actor-id <actor_id>]
17
17
  [--status <pending|approved|rejected|expired|cancelled>]
18
18
  [--candidate <rank> | --result-item <result_item_id>]
19
- [--required-before <step>] [--input <key=value> ...] [--json]
19
+ [--required-before <step>] [--input <key=value> ...]
20
+ [--input-json <file>] [--json]
20
21
  ```
21
22
 
23
+ | 选项 | 说明 |
24
+ |---|---|
25
+ | `--input <key=value>` | 逐项提交 action input;值可为 JSON 字面量(对象/数组/布尔/数字),可重复。 |
26
+ | `--input-json <file>` | UTF-8 JSON 文件;顶层必须是 object,整体作为 action input;文件不超过 256 KiB。适合嵌套结构(如订票确认的 `seat_preferences`)。**与 `--input` 互斥**:同时给出时在发起请求前返回 `service_action_invalid`。文件不存在、非合法 JSON 或顶层非 object 同样在本地报结构化错误,不发生网络写。 |
27
+
22
28
  普通 Agent 优先使用 `--candidate <rank>`。CLI 只从当前 Execution 的 `current_result_items` 解析 Result Item ID;Backend 再读取权威 Invocation 和 Stable Hash。Agent 不提交 Hash,也不能使用其他 Execution 或外部来源的候选。`--result-item` 只用于已持有当前 Execution 内部句柄的受控恢复,不应要求用户提供。
23
29
 
24
30
  ## 标准输出
@@ -45,6 +51,8 @@ itpay services action <service_execution_id> --action <action_type>
45
51
 
46
52
  rank 不存在、属于旧结果集或其他 Execution、action 不允许、status 非法时均不写 action;返回结构化错误并且只引导同一 Execution 的 `services next`。不得新建 Execution、重新 invoke 或构造候选 ID。相同候选重试幂等;同一结果集改选另一个候选返回冲突,不覆盖已批准事实。
47
53
 
54
+ 订票类 `workflow:confirm_booking` 动作要求嵌套输入(`passengers`、`seat_type`、逐乘客 `seat_preferences`、`draft_revision`、`notice_version` 等),一律经 `--input-json <file>` 提交完整 JSON 对象。`draft_revision` 必须等于 `services next` 投影的 `context.review.draft_revision`;服务端拒绝 `booking_review_changed` 时重新读取后重试,不得自行递增。乘客姓名、证件号等身份信息不在此提交,仍由受保护 Checkout 页收集。
55
+
48
56
  ## Agent Type / Host
49
57
 
50
58
  所有正式支持的 Local Agent Type 行为相同。需要人确认时 instruction 必须明确“先询问用户”,不能因 Desktop Host 自动代替用户选择。
@@ -6,9 +6,11 @@
6
6
 
7
7
  为单个 Service Execution 快速创建或恢复 Checkout,并按 Host 向人交接付款入口。它是 `services quote -> cart add --quote -> buy --cart` 的单项快捷方式,必须复用相同的 Quote、Cart 和 Checkout Use Case。
8
8
 
9
- 未注册/未登录用户不要求先执行 `itpay auth login`(Path B):官方 checkout 页内完成必要的钱包认证、创建或复用账号并取得页面权限;人在页面上确认旅客、通知邮箱和最终报价后付款。支付确认沿用既有设备绑定机制,不再独立绑定。checkout 内完成账号认证不等于账号已通过手机号验证;钱包显示名、账号手机号与乘车人身份互相独立。
9
+ 未注册/未登录用户不要求先执行 `itpay auth login`(Path B):官方 checkout 页内完成必要的钱包认证、创建或复用账号并取得页面权限;人在页面上确认旅客、通知联系方式和最终报价后付款。支付确认沿用既有设备绑定机制,不再独立绑定。checkout 内完成账号认证不等于账号已通过手机号验证;钱包显示名、账号手机号与乘车人身份互相独立。
10
10
 
11
- **上游:** `services next` 返回的 `prepare_quote` capability 和已验证输入。
11
+ **通知联系方式(铁路单):** 订单级联系策略要求至少一个可用通道——邮箱,或在 checkout 页内经验证的大陆手机号(order-contact 验证码只校验收件人,不创建/合并登录身份)。有效邮箱即可独立完成 checkout;SMS 不是必填项,SMS 能力未配置时不得阻塞邮箱通道。该策略仅适用于铁路订单,企知道等既有服务的联系策略不变。乘客证件手机号、账号登录手机号与通知收件人是三个独立事实,互不替代。
12
+
13
+ **上游:** `services next` 返回的 `prepare_quote` capability 和已验证输入。铁路购票执行必须先通过 `workflow:confirm_booking` review(见 `services next` 的 `booking_review_required`),后端才生成可支付报价;review 开放期间不创建 Checkout。
12
14
  **下游:** 人完成 Checkout,随后 `checkout` 或 `services next`。
13
15
 
14
16
  ## 语法与参数
@@ -57,6 +57,8 @@ itpay services get <service_execution_id> [--json]
57
57
 
58
58
  `timeline` 只保留最近 20 个公开节点;超过时增加 `timeline_truncated: true`。它不返回 event ID 或 `redacted_summary`。交付存在时可增加 `delivery_mode`;退款锁存在时增加 `access_locked` 和退款 ID/status,并优先返回锁定 instruction。
59
59
 
60
+ 订票类 Execution 的 `workflow:confirm_booking` 待确认/待修订状态不在 timeline 中展开细节——行程、`seat_options` 与当前 `draft_revision` 一律通过 `services next` 的 `booking_review_required` 投影读取;`get` 只用于确认执行仍处于可继续状态。
61
+
60
62
  ## 异常处理
61
63
 
62
64
  默认不返回原始 events、Provider metadata、内部 bindings、Arazzo workflow、capability schema、candidate hash、client context 或完整 payload。需要完整但仍经服务端脱敏的事件时才运行 `services events`。
@@ -249,6 +249,35 @@ itpay services run itpay-rail-booking --input-json booking.json --json
249
249
 
250
250
  `booking_offer` 只是受条件约束的可选承接:查询完成本身已满足查询目标,不自动购买;过期 token 返回明确错误,恢复方式是按 `services run` 输入合同重新查询或显式提供 `legs`。
251
251
 
252
+ ## 订票确认(booking review)
253
+
254
+ `itpay-rail-booking` 在报价前停在 `workflow:confirm_booking` 人工确认节点。`services next` 返回 `booking_review_required`,`human_action.context.review` 携带服务端投影:`draft_revision`、`legs`(含可编码的 `seat_options`)、`notice_version` 等。乘客身份信息永不在此投影,也不经 CLI 提交。
255
+
256
+ ```json
257
+ {
258
+ "status": "booking_review_required",
259
+ "result": {
260
+ "service_execution_id": "<id>",
261
+ "service_id": "itpay-rail-booking",
262
+ "human_action": {
263
+ "action_type": "workflow:confirm_booking",
264
+ "context": { "review": { "draft_revision": 1, "legs": [], "seat_options": {} } }
265
+ },
266
+ "required_fields": ["draft_revision", "passengers", "seat_type", "seat_preferences", "party_preference", "fallback", "notice_version", "accept_non_guaranteed"]
267
+ },
268
+ "instruction": "向用户展示行程与可选席别/座位偏好词表,并明确告知:座位偏好仅为请求、不保证分配。收集后经 --input-json 提交完整 JSON;draft_revision 以服务端当前值为准。",
269
+ "next": { "command": "itpay services action <id> --action workflow:confirm_booking --actor-type human --status approved --input-json <file> --json", "reason": "提交用户确认后的行程确认/修订" },
270
+ "recovery": [{ "command": "itpay services next <id> --json", "reason": "重新读取当前 draft_revision 后重试" }]
271
+ }
272
+ ```
273
+
274
+ 规则:
275
+
276
+ - `seat_preferences` 逐乘客一条,`passenger_index` 从 0 起、人数 1–5、取值限该席别 `seat_options`;`party_preference`、`fallback=automatic_assignment`、`notice_version`、`accept_non_guaranteed=true` 全部必填。
277
+ - `draft_revision` 必须等于投影值;服务端返回 `booking_review_changed` 时重新 `services next` 读取后重试,不得自行递增或重放旧 revision。
278
+ - 付款暂停且 review 仍开放时,`next` 指向 Checkout 恢复,`recovery` 附带修订命令(`services action … confirm_booking --input-json`)——修订回到同一执行重新报价,不产生第二笔订单或付款。
279
+ - 支付已在途/完成后 review 关闭,修订被拒绝;按返回的同一订单状态继续,不得新建执行。
280
+
252
281
  ## 异常处理
253
282
 
254
283
  execution 不存在或不属于当前设备/账号时返回错误信封,并仅建议:
@@ -21,7 +21,7 @@ itpay services run <service_id>
21
21
  | 参数 | 说明 |
22
22
  | --- | --- |
23
23
  | `service_id` | Catalog 返回的服务 ID。恢复时必须与 Execution 所属服务一致。 |
24
- | `--input-json` | UTF-8 JSON 文件;顶层必须是 object,并符合服务发布的 input schema。 |
24
+ | `--input-json` | UTF-8 JSON 文件;顶层必须是 object,并符合服务发布的 input schema;文件不超过 256 KiB。文件不可读、非合法 JSON 或顶层非 object 时在本地报 `workflow_input_invalid`,不发生网络写。 |
25
25
  | `--execution` | 恢复同一 Execution。不得用它切换服务或绕过原状态。 |
26
26
  | `--timeout` | 本次等待 workflow 状态变化的秒数,默认 120,范围 0–600;超时只返回当前状态。 |
27
27
  | `--host` / `--target` | Host 展示上下文,不是业务输入或 Buyer 身份。 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itpay/cli",
3
- "version": "2.1.8",
3
+ "version": "2.1.10",
4
4
  "description": "The ItPay CLI for services, orders, and human-authorized purchased content.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -76,6 +76,13 @@ platform whenever possible. Only show the same clickable URL when no browser
76
76
  or native action is available; never ask the human to run a command or rebuild
77
77
  a QR code.
78
78
 
79
+ Checkout handoffs also carry `handoff.mobile_url`: send it alongside the main
80
+ link and tell the human that on a phone it opens the cashier and can jump
81
+ straight into Alipay or WeChat Pay. When the current client cannot open links
82
+ itself (for example a mobile mini program without a side panel), send
83
+ `mobile_url` and tell the human to copy it into the phone's browser if it does
84
+ not open in place.
85
+
79
86
  ## Serve The Human
80
87
 
81
88
  - Ask the human only to choose, authorize, pay, provide required contact