@itpay/cli 2.1.7 → 2.1.9

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.
@@ -123,8 +123,9 @@ export class BackendClient {
123
123
  advanceServiceExecution(id, input, idempotencyKey) {
124
124
  return this.http.post(`/v1/service-executions/${encodeURIComponent(id)}/advance`, { input, idempotency_key: idempotencyKey });
125
125
  }
126
- createRailPhoneLink() {
127
- return this.http.post("/v1/rail/phone-links", {});
126
+ getServiceExecutionResultItemPage(serviceExecutionID, resultItemID, offset, limit) {
127
+ const qs = new URLSearchParams({ offset: String(offset), limit: String(limit) });
128
+ return this.http.get(`/v1/service-executions/${encodeURIComponent(serviceExecutionID)}/result-items/${encodeURIComponent(resultItemID)}?${qs}`);
128
129
  }
129
130
  invokeServiceCapability(serviceExecutionID, capabilityID, input) {
130
131
  return this.http.post(`/v1/service-executions/${encodeURIComponent(serviceExecutionID)}/capabilities/${encodeURIComponent(capabilityID)}/invoke`, input, { replaySafe: Boolean(input.idempotency_key) });
@@ -196,14 +196,13 @@ export async function runServicesInvoke(backend, config, serviceExecutionID, cap
196
196
  catch (error) {
197
197
  if (!(error instanceof HttpError) || error.code !== "verified_phone_required")
198
198
  throw error;
199
- const link = await backend.createRailPhoneLink();
200
199
  writeCommandEnvelope({
201
200
  status: "human_action_required",
202
- result: { service_execution_id: serviceExecutionID, ...link },
203
- instruction: "请用户打开 verification_url,在 ItPay 网页验证手机号并确认连接当前 Agent。CLI 不接收手机号或验证码;等待用户完成后再重试查询。",
204
- next: null,
205
- recovery: [{ command: `itpay services invoke ${serviceExecutionID} --capability ${capabilityID}${formatInputOptions(input)} --json`, reason: "仅在用户完成网页验证后重试" }],
206
- }, { ...options, plainResult: [`手机号验证:${link.verification_url}`] });
201
+ result: { service_execution_id: serviceExecutionID, error_code: "verified_phone_required" },
202
+ instruction: "需要在官方页面完成手机号验证后当前执行才能继续。运行 itpay auth login 打开官方登录页,完成手机号验证并绑定本设备后重试原命令;CLI 不接收手机号或验证码。",
203
+ next: { command: "itpay auth login --json", reason: "完成官方手机号验证并绑定当前设备" },
204
+ recovery: [{ command: `itpay services invoke ${serviceExecutionID} --capability ${capabilityID}${formatInputOptions(input)} --json`, reason: "仅在完成手机号验证与设备绑定后重试" }],
205
+ }, { ...options, plainResult: ["手机号验证:itpay auth login"] });
207
206
  return;
208
207
  }
209
208
  const envelope = invokedEnvelope(response, requestedCapability, readModel.capabilities, input);
@@ -770,6 +769,51 @@ export async function runServicesNext(backend, serviceExecutionID, options = {})
770
769
  plainResult: servicesNextPlainResult(envelope.result),
771
770
  });
772
771
  }
772
+ export async function runServicesPage(backend, serviceExecutionID, resultItemID, options = {}) {
773
+ const offset = options.offset ?? 0;
774
+ const limit = options.limit ?? 5;
775
+ if (!Number.isInteger(offset) || offset < 0) {
776
+ throw new CommandContractError("offset_invalid", "--offset must be a non-negative integer", "offset 必须是非负整数;本次未读取服务端分页。", [{ command: `itpay services page ${serviceExecutionID} ${resultItemID} --json`, reason: "从第一页重新读取" }]);
777
+ }
778
+ if (!Number.isInteger(limit) || limit < 1 || limit > 20) {
779
+ throw new CommandContractError("limit_invalid", "--limit must be an integer from 1 to 20", "limit 必须是 1 到 20 的整数;本次未读取服务端分页。", [{ command: `itpay services page ${serviceExecutionID} ${resultItemID} --offset ${offset} --json`, reason: "用默认页大小重试" }]);
780
+ }
781
+ const response = await backend.getServiceExecutionResultItemPage(serviceExecutionID, resultItemID, offset, limit);
782
+ const page = response.page;
783
+ const container = (page.result ?? page);
784
+ const catalogPage = (container.catalog_page ?? {});
785
+ const nextOffset = typeof catalogPage.next_offset === "number" ? catalogPage.next_offset : null;
786
+ const candidates = Array.isArray(container.candidates) ? container.candidates : [];
787
+ const envelope = {
788
+ status: candidates.length > 0 ? "result_page" : "result_page_end",
789
+ result: {
790
+ service_execution_id: response.service_execution_id,
791
+ service_capability_result_item_id: response.service_capability_result_item_id,
792
+ offset: catalogPage.offset ?? offset,
793
+ limit: catalogPage.limit ?? limit,
794
+ total: catalogPage.total ?? candidates.length,
795
+ count: catalogPage.count ?? candidates.length,
796
+ next_offset: nextOffset,
797
+ page,
798
+ },
799
+ instruction: "读取的是已保存结果的同版本分页,不重新查询、不消耗额度。用普通语言向用户说明本页候选(车次、席别、时刻、费用口径),铁路应付与地面估算费用分开表述;不要提及 safe_payload、Execution 或内部 ID。",
800
+ next: nextOffset !== null
801
+ ? { command: `itpay services page ${serviceExecutionID} ${resultItemID} --offset ${nextOffset} --json`, reason: "读取同版本结果的下一页" }
802
+ : null,
803
+ recovery: offset > 0
804
+ ? [{ command: `itpay services page ${serviceExecutionID} ${resultItemID} --json`, reason: "回到第一页" }]
805
+ : [],
806
+ };
807
+ writeCommandEnvelope(envelope, {
808
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
809
+ ...(options.output ? { output: options.output } : {}),
810
+ plainResult: candidates.map((candidate) => {
811
+ const c = candidate;
812
+ const title = typeof c.title === "string" ? c.title : JSON.stringify(c);
813
+ return `${title}`;
814
+ }),
815
+ });
816
+ }
773
817
  export async function runServicesList(backend, options = {}) {
774
818
  const limit = options.limit ?? 10;
775
819
  if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
@@ -1005,6 +1049,18 @@ function servicesNextEnvelope(model) {
1005
1049
  recovery: [],
1006
1050
  };
1007
1051
  }
1052
+ if (state === "quota_paused") {
1053
+ const login = model.workflow?.error_code !== "rate_limited";
1054
+ const resume = { command: `itpay services run ${execution.service_id} --execution ${id} --json`, reason: "登录或限流窗口后继续同一执行" };
1055
+ const quota = model.quota ? { bucket: model.quota.bucket, subject_type: model.quota.subject_type, limit: model.quota.limit, remaining: model.quota.remaining } : undefined;
1056
+ return {
1057
+ status: login ? "login_required" : "rate_limited",
1058
+ result: { service_execution_id: id, service_id: execution.service_id, ...(quota ? { quota } : {}) },
1059
+ instruction: login ? "匿名免费额度已用完。使用 itpay auth login 完成官方登录并绑定当前 Agent,然后继续同一执行;不要重新发起查询,不需要付款。" : "已达到登录账号每分钟查询上限。请等到下一分钟后继续同一执行,不要连续重试。",
1060
+ next: login ? { command: "itpay auth login --json", reason: "登录后继续同一查询执行" } : resume,
1061
+ recovery: [resume],
1062
+ };
1063
+ }
1008
1064
  if (state === "human_action" && model.workflow?.human_action) {
1009
1065
  const action = model.workflow.human_action;
1010
1066
  const requiredFields = requiredInputFields(action.input_schema);
@@ -1039,6 +1095,17 @@ function servicesNextEnvelope(model) {
1039
1095
  recovery: [],
1040
1096
  };
1041
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
+ }
1042
1109
  return {
1043
1110
  status: "confirmation_required",
1044
1111
  result: { service_execution_id: id, service_id: execution.service_id, human_action: action },
@@ -1083,7 +1150,9 @@ function servicesNextEnvelope(model) {
1083
1150
  },
1084
1151
  instruction: failedInstruction
1085
1152
  ?? (state === "payment"
1086
- ? "服务已到付款步骤,使用现有 Checkout 完成扫码付款。"
1153
+ ? (model.workflow?.human_action?.context?.review
1154
+ ? "服务已到付款步骤,可直接 Checkout 付款;如需调整席别/座位偏好,先用 confirm_booking 动作修订(会重新报价并锁定新价),完成后再付款。"
1155
+ : "服务已到付款步骤,使用现有 Checkout 完成扫码付款。")
1087
1156
  : guidance
1088
1157
  ? "按 result.guidance 的字段契约填写输入后继续同一服务执行;不要臆造字段名。"
1089
1158
  : "继续读取同一执行;缺少输入时按服务声明补齐。"),
@@ -1093,7 +1162,9 @@ function servicesNextEnvelope(model) {
1093
1162
  { command: `itpay services start ${execution.service_id} --json`, reason: "修正输入后重新发起(本执行已终止不能续用)" },
1094
1163
  ...(guidance ? [{ command: "itpay docs show rail-booking --json", reason: "查看本服务输入字段契约与示例" }] : []),
1095
1164
  ]
1096
- : [],
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
+ : [],
1097
1168
  };
1098
1169
  }
1099
1170
  const terminalEnvelope = terminalExecutionEnvelope(model);
@@ -1143,6 +1214,31 @@ function servicesNextEnvelope(model) {
1143
1214
  safe_payload: item.safe_payload,
1144
1215
  }));
1145
1216
  const selection = model.allowed_actions?.find((action) => action.type === "select_candidate");
1217
+ const railCatalog = currentItems.some((item) => {
1218
+ const container = (item.safe_payload?.result ?? item.safe_payload);
1219
+ return container?.catalog_page !== undefined || container?.cost_semantics !== undefined;
1220
+ });
1221
+ const pageRecovery = currentItems.flatMap((item) => {
1222
+ const container = (item.safe_payload?.result ?? item.safe_payload);
1223
+ const catalogPage = container?.catalog_page;
1224
+ const nextOffset = catalogPage?.next_offset;
1225
+ return typeof nextOffset === "number"
1226
+ ? [{ command: `itpay services page ${execution.service_execution_id} ${item.service_capability_result_item_id} --offset ${nextOffset} --json`, reason: "读取已保存结果的同版本下一页,不重新查询、不消耗额度" }]
1227
+ : [];
1228
+ });
1229
+ const railCostGuidance = "铁路应付只含票价加服务费(quoted_total_minor);地面接驳是单独估算(estimated_ground_minor),两者相加是已知估算(estimated_door_to_door_minor),不是收款额。";
1230
+ const instruction = (delivery?.order_id
1231
+ ? appendFeedbackPostmortemInstruction(items.length > 0
1232
+ ? selection
1233
+ ? "搜索已完成。用编号、名称和可公开字段向用户说明结果,然后停止。只有用户明确选择候选并要求继续时才执行 next.command;不要提及 safe_payload。"
1234
+ : "这一步的结果已经可用。用普通语言解释可公开字段并停止;不要提及 Arazzo、safe_payload 或内部 ID。"
1235
+ : "告诉用户本次查询得到 0 个结果并停止。Agent 不读取其他交付、不重放当前查询、修改输入或创建新查询。", "delivered")
1236
+ : items.length > 0
1237
+ ? selection
1238
+ ? "搜索已完成。用编号、名称和可公开字段向用户说明结果,然后停止。只有用户明确选择候选并要求继续时才执行 next.command;不要提及 safe_payload。"
1239
+ : "这一步的结果已经可用。用普通语言解释可公开字段并停止;不要提及 Arazzo、safe_payload 或内部 ID。"
1240
+ : "告诉用户本次查询得到 0 个结果并停止。Agent 不读取其他交付、不重放当前查询、修改输入或创建新查询。")
1241
+ + (railCatalog && items.length > 0 ? ` ${railCostGuidance}` : "");
1146
1242
  return {
1147
1243
  status: items.length > 0 ? "result_ready" : "no_result",
1148
1244
  result: {
@@ -1152,22 +1248,12 @@ function servicesNextEnvelope(model) {
1152
1248
  delivery_mode: deliveryMode,
1153
1249
  items,
1154
1250
  },
1155
- instruction: delivery?.order_id
1156
- ? appendFeedbackPostmortemInstruction(items.length > 0
1157
- ? selection
1158
- ? "搜索已完成。用编号、名称和可公开字段向用户说明结果,然后停止。只有用户明确选择候选并要求继续时才执行 next.command;不要提及 safe_payload。"
1159
- : "这一步的结果已经可用。用普通语言解释可公开字段并停止;不要提及 Arazzo、safe_payload 或内部 ID。"
1160
- : "告诉用户本次查询得到 0 个结果并停止。Agent 不读取其他交付、不重放当前查询、修改输入或创建新查询。", "delivered")
1161
- : items.length > 0
1162
- ? selection
1163
- ? "搜索已完成。用编号、名称和可公开字段向用户说明结果,然后停止。只有用户明确选择候选并要求继续时才执行 next.command;不要提及 safe_payload。"
1164
- : "这一步的结果已经可用。用普通语言解释可公开字段并停止;不要提及 Arazzo、safe_payload 或内部 ID。"
1165
- : "告诉用户本次查询得到 0 个结果并停止。Agent 不读取其他交付、不重放当前查询、修改输入或创建新查询。",
1251
+ instruction,
1166
1252
  next: selection ? {
1167
1253
  command: `itpay services action ${execution.service_execution_id} --action select_candidate --actor-type human --status approved --candidate <rank> --json`,
1168
1254
  reason: "仅在用户明确选择后锁定来源候选",
1169
1255
  } : null,
1170
- recovery: [],
1256
+ recovery: pageRecovery,
1171
1257
  };
1172
1258
  }
1173
1259
  if (deliveryMode === "vault_artifact") {
@@ -1496,6 +1582,7 @@ export async function runServicesRun(backend, config, serviceID, input, options
1496
1582
  const started = await backend.startServiceExecution({
1497
1583
  service_id: serviceID,
1498
1584
  client_context: { host: options.host ?? "terminal", ...(options.target ? { target: options.target } : {}) },
1585
+ ...(input !== undefined ? { input } : {}),
1499
1586
  });
1500
1587
  id = started.execution.service_execution_id;
1501
1588
  if (!started.workflow_entry) {
@@ -1513,6 +1600,9 @@ export async function runServicesRun(backend, config, serviceID, input, options
1513
1600
  if (input !== undefined) {
1514
1601
  model = await backend.advanceServiceExecution(id, input, `workflow-input:${id}`);
1515
1602
  }
1603
+ else if (model.workflow?.status === "quota_paused") {
1604
+ model = await backend.advanceServiceExecution(id, undefined, `workflow-resume:${id}`);
1605
+ }
1516
1606
  const until = Date.now() + (options.timeoutSeconds ?? 120) * 1000;
1517
1607
  const sleep = options.sleep ?? ((milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)));
1518
1608
  const paid = () => model.payment_bindings.some((binding) => binding.status === "payment_verified") || model.checkout_bindings.some((binding) => binding.status === "payment_verified");
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
@@ -29,7 +29,7 @@ import { runInstall } from "./commands/install.js";
29
29
  import { runSkillShow } from "./commands/skill.js";
30
30
  import { runNext } from "./commands/next.js";
31
31
  import { runVaultAccess, runVaultList, runVaultRead } from "./commands/vault.js";
32
- import { collectOption, parseKeyValueList, runServicesAction, runServicesCheckout, runServicesEvents, runServicesGet, runServicesInvoke, runServicesList, runServicesNext, runServicesReadResult, runServicesQuote, runServicesStart, } from "./commands/services.js";
32
+ import { collectOption, parseKeyValueList, runServicesAction, runServicesCheckout, runServicesEvents, runServicesGet, runServicesInvoke, runServicesList, runServicesNext, runServicesPage, runServicesReadResult, runServicesQuote, runServicesStart, } from "./commands/services.js";
33
33
  const program = new Command();
34
34
  program
35
35
  .name("itpay")
@@ -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
@@ -1231,6 +1291,25 @@ vault
1231
1291
  }
1232
1292
  });
1233
1293
  // --- service execution ----------------------------------------------------
1294
+ const WORKFLOW_INPUT_JSON_MAX_BYTES = 256 * 1024;
1295
+ function readInputJsonObject(file, code, recovery) {
1296
+ let parsed;
1297
+ try {
1298
+ if (statWorkflowInputFile(file).size > WORKFLOW_INPUT_JSON_MAX_BYTES) {
1299
+ throw new CommandContractError(code, `--input-json file exceeds ${WORKFLOW_INPUT_JSON_MAX_BYTES} bytes`, "输入文件过大;只提交服务端 input_schema 声明的字段。", recovery);
1300
+ }
1301
+ parsed = JSON.parse(readWorkflowInputFile(file, "utf8"));
1302
+ }
1303
+ catch (error) {
1304
+ if (error instanceof CommandContractError)
1305
+ throw error;
1306
+ throw new CommandContractError(code, `--input-json is not readable valid JSON: ${error instanceof Error ? error.message : String(error)}`, "确认文件存在且为合法 JSON;按服务端 input_schema 字段填写。", recovery);
1307
+ }
1308
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1309
+ throw new CommandContractError(code, "--input-json must contain a JSON object", "确认输入必须是 JSON 对象;按服务端 input_schema 字段填写。", recovery);
1310
+ }
1311
+ return parsed;
1312
+ }
1234
1313
  const services = program.command("services").description("Generic V3 Service Execution commands");
1235
1314
  services
1236
1315
  .command("run")
@@ -1245,10 +1324,11 @@ services
1245
1324
  .action(async (serviceID, options) => {
1246
1325
  const config = loadConfig();
1247
1326
  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
- }
1327
+ const input = options.inputJson
1328
+ ? readInputJsonObject(options.inputJson, "workflow_input_invalid", [
1329
+ { command: `itpay services run ${serviceID} --json`, reason: "重新读取服务输入声明" },
1330
+ ])
1331
+ : undefined;
1252
1332
  if (!Number.isFinite(options.timeout) || options.timeout < 0 || options.timeout > 600) {
1253
1333
  throw new Error("timeout must be between 0 and 600 seconds");
1254
1334
  }
@@ -1267,6 +1347,35 @@ services
1267
1347
  });
1268
1348
  }
1269
1349
  });
1350
+ services
1351
+ .command("page")
1352
+ .description("Read one page of a saved service result set (same stored version, no re-query, no quota)")
1353
+ .argument("<service_execution_id>")
1354
+ .argument("<result_item_id>")
1355
+ .option("--offset <offset>", "zero-based candidate offset", Number, 0)
1356
+ .option("--limit <limit>", "page size (1-20)", Number, 5)
1357
+ .option("--json", "output JSON")
1358
+ .action(async (serviceExecutionID, resultItemID, options) => {
1359
+ const config = loadConfig();
1360
+ const backend = newBackendClient(config);
1361
+ try {
1362
+ await runServicesPage(backend, serviceExecutionID, resultItemID, {
1363
+ offset: options.offset,
1364
+ limit: options.limit,
1365
+ jsonOutput: Boolean(options.json),
1366
+ });
1367
+ }
1368
+ catch (error) {
1369
+ reportCLIError(error, {
1370
+ jsonOutput: Boolean(options.json),
1371
+ code: "service_page_failed",
1372
+ instruction: "分页读取的是已保存的同版本结果;不要重新发起查询或新建 execution。",
1373
+ recovery: [
1374
+ { command: `itpay services next ${serviceExecutionID} --json`, reason: "读取当前结果页与合法动作" },
1375
+ ],
1376
+ });
1377
+ }
1378
+ });
1270
1379
  services
1271
1380
  .command("start")
1272
1381
  .description("Start a contract-backed service execution")
@@ -1333,12 +1442,18 @@ services
1333
1442
  .option("--result-item <service_capability_result_item_id>")
1334
1443
  .option("--required-before <step>")
1335
1444
  .option("--input <key=value>", "action input snapshot", collectOption, [])
1445
+ .option("--input-json <file>", "JSON object containing the full action input snapshot")
1336
1446
  .option("--json", "output JSON instead of terminal text")
1337
1447
  .action(async (serviceExecutionID, options) => {
1338
1448
  const config = loadConfig();
1339
1449
  const backend = newBackendClient(config);
1340
1450
  try {
1341
- await runServicesAction(backend, serviceExecutionID, options.action, parseKeyValueList(options.input), {
1451
+ const recovery = [{ command: `itpay services next ${serviceExecutionID} --json`, reason: "读取当前动作要求" }];
1452
+ if (options.inputJson && options.input.length > 0) {
1453
+ throw new CommandContractError("service_action_invalid", "--input and --input-json cannot be combined", "--input 与 --input-json 只能二选一:嵌套结构用 --input-json <file>,扁平键值用 --input key=value。", recovery);
1454
+ }
1455
+ const fileInput = options.inputJson ? readInputJsonObject(options.inputJson, "service_action_invalid", recovery) : {};
1456
+ await runServicesAction(backend, serviceExecutionID, options.action, { ...fileInput, ...parseKeyValueList(options.input) }, {
1342
1457
  ...(options.actorType ? { actorType: options.actorType } : {}),
1343
1458
  ...(options.actorId ? { actorID: options.actorId } : {}),
1344
1459
  ...(options.status ? { status: options.status } : {}),
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, rmSync } from 'node:fs';
5
+ import { writeLocalPNG } from '../render/qr.js';
5
6
  export function sellerAuthPath(baseURL, env = process.env, purpose = "seller") {
6
7
  return resolve(env.HOME || homedir(), '.itpay-v3', `${purpose}-${createHash('sha256').update(baseURL).digest('hex').slice(0, 16)}.json`);
7
8
  }
@@ -37,7 +38,13 @@ async function accountAuth(action, baseURL, env, fetcher, backend) {
37
38
  if (backend) {
38
39
  const current = await backend.agentAccountStatus();
39
40
  if (current.status === 'authenticated')
40
- return current;
41
+ return {
42
+ status: 'authenticated',
43
+ result: { bound: true, phone_verified: current.phone_verified === true },
44
+ instruction: '设备已绑定账号,可继续之前的查询或购买;登录不附带任何额外授权。',
45
+ next: { command: 'itpay services run <service_id> --execution <pending_execution_id> --json', reason: '恢复被额度暂停的原执行' },
46
+ recovery: [],
47
+ };
41
48
  }
42
49
  async function request(path, init = {}) {
43
50
  const response = await fetcher(baseURL + path, { ...init, redirect: 'error', signal: AbortSignal.timeout(15000) });
@@ -46,7 +53,28 @@ async function accountAuth(action, baseURL, env, fetcher, backend) {
46
53
  return response;
47
54
  }
48
55
  if (action === 'login') {
49
- const response = await request('/v1/dashboard/auth-sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: 'alipay', return_to: backend ? '/' : '/seller' }) });
56
+ if (backend) {
57
+ const open = read(baseURL, env, purpose);
58
+ if (open?.sessionID && open.pollToken && open.startToken) {
59
+ try {
60
+ const current = await (await request(`/v1/dashboard/auth-sessions/${encodeURIComponent(open.sessionID)}?poll_token=${encodeURIComponent(open.pollToken)}`)).json();
61
+ if (['created', 'waiting_provider', 'email_verification_required', 'merge_confirmation_required'].includes(current.status)) {
62
+ const url = open.authURL ?? '';
63
+ const qr = url ? await writeLocalPNG(url).catch(() => undefined) : undefined;
64
+ return {
65
+ status: 'auth_pending',
66
+ result: { dashboard_auth_session_id: open.sessionID, expires_at: current.expires_at, methods: ['phone', 'email', 'alipay', 'wechat'] },
67
+ handoff: { url, ...(qr ? { qr_local_path: qr.filePath, markdown: `![ItPay 官方授权二维码](${qr.filePath})` } : {}) },
68
+ instruction: '已有进行中的官方授权请求,沿用同一链接或二维码;不要生成新请求。用户在页面内选择手机号验证码、邮箱或钱包完成登录。',
69
+ next: { command: 'itpay auth status --json', reason: '用户完成页面登录后确认绑定', poll_after_ms: 5000 },
70
+ recovery: [],
71
+ };
72
+ }
73
+ }
74
+ catch { /* fall through to a fresh request */ }
75
+ }
76
+ }
77
+ const response = await request('/v1/dashboard/auth-sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ return_to: backend ? '/' : '/seller' }) });
50
78
  const result = await response.json();
51
79
  const url = new URL(result.start_url, baseURL);
52
80
  const sameOrigin = url.origin === new URL(baseURL).origin;
@@ -60,7 +88,25 @@ async function accountAuth(action, baseURL, env, fetcher, backend) {
60
88
  : url.searchParams.get('start_token') || fragment.get('start_token');
61
89
  if (!startToken || !result.poll_token || !result.dashboard_auth_session_id)
62
90
  throw new Error('Incomplete authorization response');
63
- save({ baseURL, sessionID: result.dashboard_auth_session_id, pollToken: result.poll_token, startToken }, env, purpose);
91
+ save({ baseURL, sessionID: result.dashboard_auth_session_id, pollToken: result.poll_token, startToken, authURL: url.href }, env, purpose);
92
+ if (backend) {
93
+ const qr = await writeLocalPNG(url.href).catch(() => undefined);
94
+ return {
95
+ status: 'auth_pending',
96
+ result: {
97
+ dashboard_auth_session_id: result.dashboard_auth_session_id,
98
+ expires_at: result.expires_at,
99
+ methods: ['phone', 'email', 'alipay', 'wechat'],
100
+ },
101
+ handoff: {
102
+ url: url.href,
103
+ ...(qr ? { qr_local_path: qr.filePath, markdown: `![ItPay 官方授权二维码](${qr.filePath})` } : {}),
104
+ },
105
+ instruction: '把官方授权页或二维码交给用户。用户在页面内选择手机号验证码、邮箱或钱包完成登录;不要替用户输入手机号或验证码,start_token 不要写入聊天记录。',
106
+ next: { command: 'itpay auth status --json', reason: '用户完成页面登录后确认绑定', poll_after_ms: 5000 },
107
+ recovery: [],
108
+ };
109
+ }
64
110
  return { status: 'authorization_required', authorization_url: url.href, instruction: `Complete ItPay login in the browser, then run ${command}.` };
65
111
  }
66
112
  const state = read(baseURL, env, purpose);
@@ -77,14 +123,50 @@ async function accountAuth(action, baseURL, env, fetcher, backend) {
77
123
  return { status: 'login_required' };
78
124
  const path = `/v1/dashboard/auth-sessions/${encodeURIComponent(state.sessionID)}`;
79
125
  const progress = await (await request(`${path}?poll_token=${encodeURIComponent(state.pollToken)}`)).json();
80
- if (progress.status !== 'completed')
126
+ if (progress.status !== 'completed') {
127
+ if (backend) {
128
+ if (progress.status === 'expired') {
129
+ rmSync(sellerAuthPath(baseURL, env, purpose), { force: true });
130
+ return {
131
+ status: 'auth_expired',
132
+ result: { dashboard_auth_session_id: state.sessionID },
133
+ instruction: '授权请求已过期。重新运行 itpay auth login 生成新请求;之前的查询输入在服务端保留,可恢复。',
134
+ next: { command: 'itpay auth login --json', reason: '重新发起官方授权' },
135
+ recovery: [],
136
+ };
137
+ }
138
+ if (progress.status === 'failed' || progress.status === 'cancelled' || progress.status === 'denied') {
139
+ rmSync(sellerAuthPath(baseURL, env, purpose), { force: true });
140
+ return {
141
+ status: progress.status === 'failed' ? 'auth_denied' : `auth_${progress.status}`,
142
+ result: { dashboard_auth_session_id: state.sessionID },
143
+ instruction: '官方授权未通过或被取消。重新运行 itpay auth login 生成新请求。',
144
+ next: { command: 'itpay auth login --json', reason: '重新发起官方授权' },
145
+ recovery: [],
146
+ };
147
+ }
148
+ return {
149
+ status: 'auth_pending',
150
+ result: { dashboard_auth_session_id: state.sessionID },
151
+ instruction: '用户仍在官方页面完成登录;保留当前 handoff,不要生成新二维码或新请求。',
152
+ next: { command: 'itpay auth status --json', reason: '轮询同一授权请求', poll_after_ms: 5000 },
153
+ recovery: [],
154
+ };
155
+ }
81
156
  return { status: progress.status, instruction: 'Finish login and email verification in the browser.' };
157
+ }
82
158
  if (backend) {
83
159
  const result = await backend.bindAgentAccount({ dashboard_auth_session_id: state.sessionID, start_token: state.startToken });
84
160
  if (result.status !== 'authenticated')
85
161
  throw new Error('Agent binding did not complete');
86
162
  rmSync(sellerAuthPath(baseURL, env, purpose), { force: true });
87
- return result;
163
+ return {
164
+ status: 'authenticated',
165
+ result: { bound: true, phone_verified: result.phone_verified === true },
166
+ instruction: '登录与绑定已完成,告知用户可继续之前的查询;已注册账号查询不消耗试用次数,仍受正常限流。',
167
+ next: { command: 'itpay services run <service_id> --execution <pending_execution_id> --json', reason: '恢复被额度暂停的原查询' },
168
+ recovery: [],
169
+ };
88
170
  }
89
171
  const claimed = await request(`${path}/claim?start_token=${encodeURIComponent(state.startToken)}`, { method: 'POST' });
90
172
  const token = /(?:^|[, ]+)itpay_buyer_session=([^;]+)/.exec(claimed.headers.get('set-cookie') ?? '')?.[1];
@@ -15,8 +15,8 @@ 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.7";
19
- export const API_CONTRACT_REVISION = "sha256:42659989d533d5a43c4ab92eb2455bcbbc6456e799fa4c2b6587de77f06b95f7";
18
+ export const CLI_VERSION = "2.1.9";
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";
22
22
  const OPERATION_JOURNAL_FILENAME = "operations.json";