@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.
- package/dist/src/client/backend.js +3 -2
- package/dist/src/commands/services.js +110 -20
- package/dist/src/main.js +127 -12
- package/dist/src/state/account_auth.js +87 -5
- package/dist/src/state/config.js +2 -2
- package/dist/src/state/device_authority.js +64 -12
- package/docs/agent/buyer/identity-and-sessions.json +9 -2
- package/docs/agent/buyer/rail-booking.json +97 -12
- package/docs/cli-reference/commands/auth.md +89 -4
- package/docs/cli-reference/commands/device.md +13 -1
- package/docs/cli-reference/commands/services/action.md +9 -1
- package/docs/cli-reference/commands/services/checkout.md +5 -1
- package/docs/cli-reference/commands/services/get.md +2 -0
- package/docs/cli-reference/commands/services/next.md +81 -0
- package/docs/cli-reference/commands/services/page.md +67 -0
- package/docs/cli-reference/commands/services/run.md +81 -1
- package/docs/cli-reference/index.md +1 -0
- package/package.json +1 -1
- package/skills/itpay/SKILL.md +19 -0
|
@@ -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
|
-
|
|
127
|
-
|
|
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,
|
|
203
|
-
instruction: "
|
|
204
|
-
next:
|
|
205
|
-
recovery: [{ command: `itpay services invoke ${serviceExecutionID} --capability ${capabilityID}${formatInputOptions(input)} --json`, reason: "
|
|
206
|
-
}, { ...options, plainResult: [
|
|
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
|
-
?
|
|
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
|
|
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
|
|
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
|
-
? "
|
|
156
|
-
:
|
|
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
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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: `` } : {}) },
|
|
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: `` } : {}),
|
|
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
|
|
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];
|
package/dist/src/state/config.js
CHANGED
|
@@ -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.
|
|
19
|
-
export const API_CONTRACT_REVISION = "sha256:
|
|
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";
|