@itpay/cli 2.1.17 → 2.1.19
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/commands/checkout.js +109 -2
- package/dist/src/commands/guidance.js +27 -0
- package/dist/src/commands/order.js +8 -1
- package/dist/src/commands/presentation.js +153 -0
- package/dist/src/commands/services.js +314 -61
- package/dist/src/main.js +73 -17
- package/dist/src/render/browser.js +65 -0
- package/dist/src/state/account_auth.js +196 -49
- package/dist/src/state/client_context.js +70 -0
- package/dist/src/state/config.js +13 -2
- package/dist/src/state/device_authority.js +69 -13
- package/dist/src/state/task_journal.js +88 -0
- package/docs/agent/buyer/identity-and-sessions.json +9 -1
- package/docs/agent/buyer/install-and-setup.json +2 -2
- package/docs/agent/buyer/rail-booking.json +23 -10
- package/docs/agent/buyer/rail-fast-checkout.json +68 -0
- package/docs/cli-reference/commands/checkout.md +10 -1
- package/docs/reviews/agent-ux-handoff-2026-09-23/IMPLEMENTATION-EVIDENCE.md +174 -0
- package/package.json +1 -1
- package/skills/itpay/SKILL.md +109 -5
package/dist/src/main.js
CHANGED
|
@@ -7,7 +7,7 @@ import { runServicesRun } from "./commands/services.js";
|
|
|
7
7
|
// orchestrate; HTTP and rendering live in src/client and src/render.
|
|
8
8
|
import { Command } from "commander";
|
|
9
9
|
import { BackendOverrideError, CLI_VERSION, cliDistribution, loadConfig, cartSessionPath, newBackendClient } from "./state/config.js";
|
|
10
|
-
import { DeviceAuthority, DeviceAuthorizationError, DeviceStateError } from "./state/device_authority.js";
|
|
10
|
+
import { DeviceAuthority, DeviceAuthorizationError, DeviceLockBusyError, DeviceStateError } from "./state/device_authority.js";
|
|
11
11
|
import { CartSession } from "./state/cart_session.js";
|
|
12
12
|
import { defaultHostForAgentType, normalizeHost, validateContext } from "./state/client_context.js";
|
|
13
13
|
import { HttpError } from "./client/http.js";
|
|
@@ -116,6 +116,7 @@ function reportCLIError(error, contract) {
|
|
|
116
116
|
const backendOverrideError = error instanceof BackendOverrideError ? error : undefined;
|
|
117
117
|
const deviceError = error instanceof DeviceAuthorizationError ? error : undefined;
|
|
118
118
|
const stateError = error instanceof DeviceStateError ? error : undefined;
|
|
119
|
+
const lockError = error instanceof DeviceLockBusyError ? error : undefined;
|
|
119
120
|
const transportError = error instanceof HttpTransportError ? error : undefined;
|
|
120
121
|
const httpRecovery = errorRecoveryActions(error).map((action) => ({
|
|
121
122
|
command: action.command,
|
|
@@ -154,24 +155,30 @@ function reportCLIError(error, contract) {
|
|
|
154
155
|
command: "itpay skill show itpay --json",
|
|
155
156
|
reason: "读取 Device 状态边界;修复当前 Host 的持久写权限后重试原命令",
|
|
156
157
|
}] : [];
|
|
158
|
+
const lockRecovery = lockError ? [{
|
|
159
|
+
command: "itpay device repair-lock --json",
|
|
160
|
+
reason: "检查锁持有者;只回收已退出进程留下的锁,不改变设备身份",
|
|
161
|
+
}] : [];
|
|
157
162
|
const authorizationInstruction = stateError
|
|
158
163
|
? "当前运行环境无法写入 owner-only Device 状态;请保持同一 Node、CLI 和 Agent Type,在允许持久写入 ~/.itpay-v3 的执行环境中重试。不要手工创建 lock、删除 identity 或换运行时碰运气。"
|
|
159
|
-
:
|
|
160
|
-
? "
|
|
161
|
-
:
|
|
162
|
-
? "
|
|
163
|
-
:
|
|
164
|
-
? "
|
|
165
|
-
: deviceKeyResettable
|
|
166
|
-
? "
|
|
167
|
-
:
|
|
168
|
-
? "
|
|
169
|
-
:
|
|
170
|
-
|
|
164
|
+
: lockError
|
|
165
|
+
? "另一个进程仍在更新本地身份。执行 recovery 检查;若持有者仍活跃,等待后继续原任务,不要删除锁或重建身份。"
|
|
166
|
+
: error instanceof HttpError && error.code === "agent_device_session_required"
|
|
167
|
+
? "CLI 已自动续期并重试同一请求一次,仍被拒绝;停止重试,不要切换 Agent Type 或旋转身份。"
|
|
168
|
+
: deviceError?.code === "agent_device_revoked"
|
|
169
|
+
? "Backend 已撤销当前 Device 登记;CLI 没有自动创建替代身份。停止重试并请用户或运营恢复登记。"
|
|
170
|
+
: deviceKeyResettable && deviceError?.status === 500
|
|
171
|
+
? "服务端未能完成设备登记。先原样重试一次原命令:服务端会把已登记的同一公钥幂等挂回原设备。若仍返回 internal_error(Backend 未含该修复),执行 itpay device reset-key --confirm-key-reset 生成全新密钥后重试。"
|
|
172
|
+
: deviceKeyResettable
|
|
173
|
+
? "服务端记录显示当前设备私钥已不再有效(已轮换或与既有登记冲突)。执行 itpay device reset-key --confirm-key-reset 生成全新密钥并重新登记。"
|
|
174
|
+
: deviceError
|
|
175
|
+
? "Device 身份验证失败;停止重试,不要切换 Agent Type、删除状态或旋转私钥。"
|
|
176
|
+
: undefined;
|
|
177
|
+
if (contract || commandError || backendOverrideError || lockError || stateError) {
|
|
171
178
|
writeCommandEnvelope({
|
|
172
179
|
status: "error",
|
|
173
180
|
error: {
|
|
174
|
-
code: incompatible ? "backend_contract_incompatible" : backendOverrideError?.code ?? commandError?.code ?? (error instanceof HttpError ? error.code : transportError?.code ?? stateError?.code ?? deviceError?.code ?? contract?.code ?? "command_failed"),
|
|
181
|
+
code: incompatible ? "backend_contract_incompatible" : backendOverrideError?.code ?? commandError?.code ?? (error instanceof HttpError ? error.code : transportError?.code ?? stateError?.code ?? lockError?.code ?? deviceError?.code ?? contract?.code ?? "command_failed"),
|
|
175
182
|
message: error instanceof Error ? error.message : String(error),
|
|
176
183
|
},
|
|
177
184
|
...(requiredCLIVersion ? {
|
|
@@ -228,9 +235,9 @@ function reportCLIError(error, contract) {
|
|
|
228
235
|
: []
|
|
229
236
|
: backendInternal || providerConnectionUnavailable || providerTemporary || providerInputRejected || providerContractMismatch || providerRejected || capabilityInputInvalid
|
|
230
237
|
? []
|
|
231
|
-
: backendOverrideError ? [] : commandError?.recovery ?? (stateError ? stateRecovery : deviceError ? deviceRecovery : identityRecovery ? httpRecovery : contract?.recovery ?? []),
|
|
238
|
+
: backendOverrideError ? [] : commandError?.recovery ?? (stateError ? stateRecovery : lockError ? lockRecovery : deviceError ? deviceRecovery : identityRecovery ? httpRecovery : contract?.recovery ?? []),
|
|
232
239
|
}, {
|
|
233
|
-
...(contract?.jsonOutput !== undefined ? { jsonOutput: contract.jsonOutput } : backendOverrideError ? { jsonOutput: process.argv.includes("--json") } : {}),
|
|
240
|
+
...(contract?.jsonOutput !== undefined ? { jsonOutput: contract.jsonOutput } : backendOverrideError || lockError || stateError ? { jsonOutput: process.argv.includes("--json") } : {}),
|
|
234
241
|
output: (text) => { process.stderr.write(text); },
|
|
235
242
|
});
|
|
236
243
|
process.exitCode = 1;
|
|
@@ -311,7 +318,18 @@ for (const action of ["login", "status"]) {
|
|
|
311
318
|
const config = loadConfig();
|
|
312
319
|
try {
|
|
313
320
|
const result = await agentAuth(action, config.baseURL, newBackendClient(config));
|
|
314
|
-
|
|
321
|
+
// AUTH06: auth output goes through the same envelope renderer as every
|
|
322
|
+
// other command — next/recovery keep Agent Type + Backend qualification
|
|
323
|
+
// and non-JSON output is human-readable instead of a raw JSON dump.
|
|
324
|
+
const envelope = {
|
|
325
|
+
status: String(result.status ?? "error"),
|
|
326
|
+
result: (result.result ?? result),
|
|
327
|
+
...(result.handoff ? { handoff: result.handoff } : {}),
|
|
328
|
+
instruction: typeof result.instruction === "string" ? result.instruction : "按返回状态继续。",
|
|
329
|
+
next: (result.next ?? null),
|
|
330
|
+
recovery: (result.recovery ?? []),
|
|
331
|
+
};
|
|
332
|
+
writeCommandEnvelope(envelope, { jsonOutput: Boolean(options.json) });
|
|
315
333
|
}
|
|
316
334
|
catch (error) {
|
|
317
335
|
reportCLIError(error, { jsonOutput: Boolean(options.json), code: "account_login_failed", instruction: "完成官方网页登录后重试 itpay auth status;不要清除设备登记。" });
|
|
@@ -320,6 +338,30 @@ for (const action of ["login", "status"]) {
|
|
|
320
338
|
}
|
|
321
339
|
// --- device ---------------------------------------------------------------
|
|
322
340
|
const deviceCmd = program.command("device").description("Recover the current official Backend registration after an operator-confirmed reset");
|
|
341
|
+
deviceCmd
|
|
342
|
+
.command("repair-lock")
|
|
343
|
+
.description("Safely inspect and recover a local Device identity lock left by an exited process")
|
|
344
|
+
.option("--json", "output JSON instead of terminal text")
|
|
345
|
+
.action((options) => {
|
|
346
|
+
try {
|
|
347
|
+
const config = loadConfig();
|
|
348
|
+
const status = new DeviceAuthority({
|
|
349
|
+
baseURL: config.baseURL,
|
|
350
|
+
...(config.agentType ? { requestedAgentType: config.agentType } : {}),
|
|
351
|
+
compatibilityHeaders: {},
|
|
352
|
+
}).repairLock();
|
|
353
|
+
writeCommandEnvelope({
|
|
354
|
+
status: status === "recovered" ? "device_lock_recovered" : status === "active" ? "device_lock_active" : "device_lock_absent",
|
|
355
|
+
result: { lock_status: status, device_identity_preserved: true },
|
|
356
|
+
instruction: status === "active" ? "本地身份正由存活进程更新;稍后继续原任务。" : "本地锁无需进一步处理;继续原任务。",
|
|
357
|
+
next: null,
|
|
358
|
+
recovery: [],
|
|
359
|
+
}, { jsonOutput: Boolean(options.json), plainResult: [`lock: ${status}`, "device_identity: preserved"] });
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
reportCLIError(error, { jsonOutput: Boolean(options.json), code: "device_lock_repair_failed", instruction: "本地锁无法安全检查;保留设备身份并联系维护者。" });
|
|
363
|
+
}
|
|
364
|
+
});
|
|
323
365
|
deviceCmd
|
|
324
366
|
.command("recover")
|
|
325
367
|
.description("Forget only the current official Backend registration while preserving the local private key")
|
|
@@ -893,6 +935,13 @@ program
|
|
|
893
935
|
.option("--id <checkout_id>")
|
|
894
936
|
.option("--token <display_token>")
|
|
895
937
|
.option("--locale <locale>", "payment card language: zh-CN|en", "zh-CN")
|
|
938
|
+
.option("--present <method>", "display once: auto|browser|image|link|none (JSON default plans only)")
|
|
939
|
+
.option("--no-open", "never open a browser window")
|
|
940
|
+
.option("--viewer <viewer>", "device the human is looking at: desktop|mobile|unknown")
|
|
941
|
+
.option("--relay-option <ref>", "server-issued contact option reference for message relay")
|
|
942
|
+
.option("--confirm-relay", "confirm the human explicitly consented to this relay send")
|
|
943
|
+
.option("--request-key <key>", "stable idempotency key for a relay request")
|
|
944
|
+
.option("--relay-status <relay_id>", "read a prior relay result; never resends")
|
|
896
945
|
.option("--json", "output compact JSON")
|
|
897
946
|
.action(async (options) => {
|
|
898
947
|
const config = loadConfig();
|
|
@@ -919,6 +968,13 @@ program
|
|
|
919
968
|
baseURL: config.baseURL,
|
|
920
969
|
locale: options.locale,
|
|
921
970
|
jsonOutput: Boolean(options.json),
|
|
971
|
+
...(options.present ? { present: options.present } : {}),
|
|
972
|
+
...(options.noOpen ? { noOpen: true } : {}),
|
|
973
|
+
...(options.viewer ? { viewer: options.viewer } : {}),
|
|
974
|
+
...(options.relayOption ? { relayOption: options.relayOption } : {}),
|
|
975
|
+
...(options.confirmRelay ? { confirmRelay: true } : {}),
|
|
976
|
+
...(options.requestKey ? { requestKey: options.requestKey } : {}),
|
|
977
|
+
...(options.relayStatus ? { relayStatus: options.relayStatus } : {}),
|
|
922
978
|
});
|
|
923
979
|
}
|
|
924
980
|
catch (error) {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Local browser dispatch for `--present browser`. Opens the URL via the OS
|
|
2
|
+
// opener with an argv array — never a model-generated shell string. Dispatch
|
|
3
|
+
// proves only that the request was accepted by the OS; it never means the
|
|
4
|
+
// page became visible (that would be `page_loaded`, which needs evidence).
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { platform } from "node:os";
|
|
7
|
+
// Official HTTPS origins (or the loopback dev origin behind ITPAY_CLI_DEV).
|
|
8
|
+
// No userinfo, no non-http(s) schemes, no redirect chains — the opener gets
|
|
9
|
+
// exactly this URL once.
|
|
10
|
+
export function isPresentableURL(raw, baseURL, env = process.env) {
|
|
11
|
+
let url;
|
|
12
|
+
try {
|
|
13
|
+
url = new URL(raw);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
if (url.username || url.password)
|
|
19
|
+
return false;
|
|
20
|
+
if (env.ITPAY_CLI_DEV === "1" && /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(url.origin))
|
|
21
|
+
return true;
|
|
22
|
+
if (url.protocol !== "https:")
|
|
23
|
+
return false;
|
|
24
|
+
const allowedOrigins = new Set(["https://app.itpay.ai", "https://sandbox.itpay.ai", "https://dev.itpay.ai"]);
|
|
25
|
+
if (baseURL) {
|
|
26
|
+
try {
|
|
27
|
+
allowedOrigins.add(new URL(baseURL).origin);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
/* ignore malformed base URL */
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return allowedOrigins.has(url.origin);
|
|
34
|
+
}
|
|
35
|
+
export function openInSystemBrowser(raw, baseURL, env = process.env) {
|
|
36
|
+
if (!isPresentableURL(raw, baseURL, env)) {
|
|
37
|
+
return Promise.resolve({ status: "failed", reason: "url_rejected" });
|
|
38
|
+
}
|
|
39
|
+
const [command, args] = openerArgv(raw);
|
|
40
|
+
if (!command) {
|
|
41
|
+
return Promise.resolve({ status: "unavailable", reason: "no system opener on this platform" });
|
|
42
|
+
}
|
|
43
|
+
return new Promise((resolvePromise) => {
|
|
44
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
45
|
+
child.once("error", (error) => resolvePromise({ status: "failed", reason: error.message }));
|
|
46
|
+
child.once("spawn", () => {
|
|
47
|
+
child.unref();
|
|
48
|
+
resolvePromise({ status: "dispatch_accepted", argv: [command, ...args] });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function openerArgv(url) {
|
|
53
|
+
switch (platform()) {
|
|
54
|
+
case "darwin":
|
|
55
|
+
return ["open", [url]];
|
|
56
|
+
case "win32":
|
|
57
|
+
return ["cmd.exe", ["/c", "start", "", url]];
|
|
58
|
+
case "linux":
|
|
59
|
+
case "freebsd":
|
|
60
|
+
case "openbsd":
|
|
61
|
+
return ["xdg-open", [url]];
|
|
62
|
+
default:
|
|
63
|
+
return [undefined, []];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -3,6 +3,8 @@ 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
5
|
import { writeLocalPNG } from '../render/qr.js';
|
|
6
|
+
import { TaskJournal } from './task_journal.js';
|
|
7
|
+
import { taskJournalPath } from './config.js';
|
|
6
8
|
export function sellerAuthPath(baseURL, env = process.env, purpose = "seller") {
|
|
7
9
|
return resolve(env.HOME || homedir(), '.itpay-v3', `${purpose}-${createHash('sha256').update(baseURL).digest('hex').slice(0, 16)}.json`);
|
|
8
10
|
}
|
|
@@ -32,46 +34,176 @@ export function sellerAuth(action, baseURL, env = process.env, fetcher = fetch)
|
|
|
32
34
|
export function agentAuth(action, baseURL, backend, env = process.env, fetcher = fetch) {
|
|
33
35
|
return accountAuth(action, baseURL, env, fetcher, backend);
|
|
34
36
|
}
|
|
37
|
+
// AUTH01: dashboard session stages stay distinct — the human must know exactly
|
|
38
|
+
// what the official page still needs instead of a generic "pending".
|
|
39
|
+
const ACTIVE_SESSION_STAGES = ['created', 'waiting_provider', 'email_verification_required', 'merge_confirmation_required'];
|
|
40
|
+
function stageInstruction(stage) {
|
|
41
|
+
if (stage === 'email_verification_required') {
|
|
42
|
+
return '钱包授权已完成,官方页还需要完成联系方式验证(邮箱或手机,以页面实际提供的方式为准)。完成后继续原查询,不用重说行程。';
|
|
43
|
+
}
|
|
44
|
+
if (stage === 'merge_confirmation_required') {
|
|
45
|
+
return '官方页需要用户确认账号合并决定后才能完成登录;不要替用户选择,也不要新建授权请求。';
|
|
46
|
+
}
|
|
47
|
+
return '用户仍在官方页面完成登录;保留当前 handoff,不要生成新二维码或新请求。';
|
|
48
|
+
}
|
|
49
|
+
// AUTH07: distinct outcomes stay distinct — a technical failure is never
|
|
50
|
+
// reported as a user denial, and a missing session is its own state.
|
|
51
|
+
const TERMINAL_SESSION_STATES = {
|
|
52
|
+
expired: {
|
|
53
|
+
status: 'auth_expired',
|
|
54
|
+
instruction: '授权请求已过期。重新运行 itpay auth login 生成新请求;之前的查询输入在服务端保留,可恢复。',
|
|
55
|
+
},
|
|
56
|
+
failed: {
|
|
57
|
+
status: 'auth_failed',
|
|
58
|
+
instruction: '官方授权因技术原因未完成(不是用户拒绝)。重新运行 itpay auth login 生成新请求。',
|
|
59
|
+
},
|
|
60
|
+
cancelled: {
|
|
61
|
+
status: 'auth_cancelled',
|
|
62
|
+
instruction: '用户在官方页取消了授权。如仍需登录,重新运行 itpay auth login。',
|
|
63
|
+
},
|
|
64
|
+
denied: {
|
|
65
|
+
status: 'auth_denied',
|
|
66
|
+
instruction: '官方授权被拒绝。重新运行 itpay auth login 生成新请求。',
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
class AuthRequestError extends Error {
|
|
70
|
+
httpStatus;
|
|
71
|
+
constructor(httpStatus, message) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.httpStatus = httpStatus;
|
|
74
|
+
this.name = 'AuthRequestError';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// AUTH03: transport/read uncertainty never maps to "denied" or a new session —
|
|
78
|
+
// the stored session stays on disk and the caller retries the same poll.
|
|
79
|
+
function authStatusUnknown(sessionID) {
|
|
80
|
+
return {
|
|
81
|
+
status: 'auth_status_unknown',
|
|
82
|
+
result: sessionID ? { dashboard_auth_session_id: sessionID } : {},
|
|
83
|
+
instruction: '读取官方授权状态时遇到网络或服务端临时故障;原授权请求已保留。稍后重读同一状态,不要新建授权、不要清除本地登记。',
|
|
84
|
+
next: { command: 'itpay auth status --json', reason: '稍后重读同一授权状态', poll_after_ms: 5000 },
|
|
85
|
+
recovery: [],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function sessionGone(env, purpose, baseURL, sessionID) {
|
|
89
|
+
rmSync(sellerAuthPath(baseURL, env, purpose), { force: true });
|
|
90
|
+
return {
|
|
91
|
+
status: 'auth_session_missing',
|
|
92
|
+
result: sessionID ? { dashboard_auth_session_id: sessionID } : {},
|
|
93
|
+
instruction: '之前的授权请求在服务端已不存在。重新运行 itpay auth login 生成新请求。',
|
|
94
|
+
next: { command: 'itpay auth login --json', reason: '重新发起官方授权' },
|
|
95
|
+
recovery: [],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// AUTH04: a successful bind resumes the SAME task that paused for login —
|
|
99
|
+
// from the local task journal, never a placeholder and never a guess at the
|
|
100
|
+
// latest order.
|
|
101
|
+
function authenticatedEnvelope(env, phoneVerified) {
|
|
102
|
+
const paused = new TaskJournal(taskJournalPath(env)).pausedTasks()
|
|
103
|
+
.filter((task) => task.stage === 'login_required' || task.stage === 'quota_paused');
|
|
104
|
+
const resumable = paused.filter((task) => typeof task.resume_command === 'string');
|
|
105
|
+
const result = { bound: true, phone_verified: phoneVerified };
|
|
106
|
+
let instruction = '登录与绑定已完成,告知用户可继续之前的查询;已注册账号查询不消耗试用次数,仍受正常限流。';
|
|
107
|
+
let next = null;
|
|
108
|
+
if (resumable.length === 1) {
|
|
109
|
+
next = { command: resumable[0].resume_command, reason: '恢复登录前暂停的同一查询' };
|
|
110
|
+
}
|
|
111
|
+
else if (resumable.length > 1) {
|
|
112
|
+
result.paused_executions = resumable.map((task) => ({
|
|
113
|
+
service_execution_id: task.service_execution_id,
|
|
114
|
+
...(task.service_id ? { service_id: task.service_id } : {}),
|
|
115
|
+
resume_command: task.resume_command,
|
|
116
|
+
}));
|
|
117
|
+
instruction += ' 有多个暂停的查询:只恢复与当前用户目标对应的那一个,不要一次恢复全部。';
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
instruction += ' 当前没有待恢复的暂停查询;不构造恢复命令。';
|
|
121
|
+
}
|
|
122
|
+
return { status: 'authenticated', result, instruction, next, recovery: [] };
|
|
123
|
+
}
|
|
35
124
|
async function accountAuth(action, baseURL, env, fetcher, backend) {
|
|
36
125
|
const purpose = backend ? 'agent-login' : 'seller';
|
|
37
126
|
const command = backend ? 'itpay auth status' : 'itpay sell auth status';
|
|
38
127
|
if (backend) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
128
|
+
try {
|
|
129
|
+
const current = await backend.agentAccountStatus();
|
|
130
|
+
if (current.status === 'authenticated')
|
|
131
|
+
return authenticatedEnvelope(env, current.phone_verified === true);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error instanceof AuthRequestError)
|
|
135
|
+
throw error;
|
|
136
|
+
// AUTH03: even the binding-status probe can hit transport failure; the
|
|
137
|
+
// truthful state is "unknown", never an implicit unbound or a fresh login.
|
|
138
|
+
return authStatusUnknown(undefined);
|
|
139
|
+
}
|
|
48
140
|
}
|
|
49
141
|
async function request(path, init = {}) {
|
|
50
142
|
const response = await fetcher(baseURL + path, { ...init, redirect: 'error', signal: AbortSignal.timeout(15000) });
|
|
51
143
|
if (!response.ok)
|
|
52
|
-
throw new
|
|
144
|
+
throw new AuthRequestError(response.status, `ItPay authorization failed (${response.status}); retry login if expired`);
|
|
53
145
|
return response;
|
|
54
146
|
}
|
|
147
|
+
function pollOpenSession(state) {
|
|
148
|
+
return request(`/v1/dashboard/auth-sessions/${encodeURIComponent(state.sessionID)}?poll_token=${encodeURIComponent(state.pollToken)}`)
|
|
149
|
+
.then((response) => response.json());
|
|
150
|
+
}
|
|
151
|
+
async function bindSavedSession(state) {
|
|
152
|
+
const bound = await backend.bindAgentAccount({ dashboard_auth_session_id: state.sessionID, start_token: state.startToken });
|
|
153
|
+
if (bound.status !== 'authenticated')
|
|
154
|
+
throw new Error('Agent binding did not complete');
|
|
155
|
+
rmSync(sellerAuthPath(baseURL, env, purpose), { force: true });
|
|
156
|
+
return authenticatedEnvelope(env, bound.phone_verified === true);
|
|
157
|
+
}
|
|
55
158
|
if (action === 'login') {
|
|
56
159
|
if (backend) {
|
|
57
160
|
const open = read(baseURL, env, purpose);
|
|
58
161
|
if (open?.sessionID && open.pollToken && open.startToken) {
|
|
59
162
|
try {
|
|
60
|
-
const current = await (
|
|
61
|
-
|
|
163
|
+
const current = await pollOpenSession(open);
|
|
164
|
+
// AUTH02: a completed-but-unbound session binds in place; never
|
|
165
|
+
// create a second auth session for the same login.
|
|
166
|
+
if (current.status === 'completed')
|
|
167
|
+
return await bindSavedSession(open);
|
|
168
|
+
if (ACTIVE_SESSION_STAGES.includes(current.status)) {
|
|
62
169
|
const url = open.authURL ?? '';
|
|
63
170
|
const qr = url ? await writeLocalPNG(url).catch(() => undefined) : undefined;
|
|
64
171
|
return {
|
|
65
172
|
status: 'auth_pending',
|
|
66
|
-
result: {
|
|
67
|
-
|
|
68
|
-
|
|
173
|
+
result: {
|
|
174
|
+
dashboard_auth_session_id: open.sessionID,
|
|
175
|
+
stage: current.status,
|
|
176
|
+
expires_at: current.expires_at,
|
|
177
|
+
methods: ['phone', 'email', 'alipay', 'wechat'],
|
|
178
|
+
},
|
|
179
|
+
// SEC01: the handoff URL carries one-time credentials — it goes
|
|
180
|
+
// to the human only, never into logs, prompts, or PR material.
|
|
181
|
+
handoff: {
|
|
182
|
+
url,
|
|
183
|
+
contains_credentials: true,
|
|
184
|
+
...(qr ? { qr_local_path: qr.filePath, markdown: `` } : {}),
|
|
185
|
+
},
|
|
186
|
+
instruction: `${stageInstruction(current.status)} 沿用同一链接或二维码;不要生成新请求。`,
|
|
69
187
|
next: { command: 'itpay auth status --json', reason: '用户完成页面登录后确认绑定', poll_after_ms: 5000 },
|
|
70
188
|
recovery: [],
|
|
71
189
|
};
|
|
72
190
|
}
|
|
191
|
+
const terminal = TERMINAL_SESSION_STATES[current.status];
|
|
192
|
+
if (terminal) {
|
|
193
|
+
rmSync(sellerAuthPath(baseURL, env, purpose), { force: true });
|
|
194
|
+
// Fall through to a fresh session only after a definitive
|
|
195
|
+
// terminal state; anything else keeps the stored session.
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
return authStatusUnknown(open.sessionID);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (error instanceof AuthRequestError && (error.httpStatus === 404 || error.httpStatus === 410)) {
|
|
203
|
+
return sessionGone(env, purpose, baseURL, open.sessionID);
|
|
204
|
+
}
|
|
205
|
+
return authStatusUnknown(open.sessionID);
|
|
73
206
|
}
|
|
74
|
-
catch { /* fall through to a fresh request */ }
|
|
75
207
|
}
|
|
76
208
|
}
|
|
77
209
|
const response = await request('/v1/dashboard/auth-sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ return_to: backend ? '/' : '/seller' }) });
|
|
@@ -95,14 +227,16 @@ async function accountAuth(action, baseURL, env, fetcher, backend) {
|
|
|
95
227
|
status: 'auth_pending',
|
|
96
228
|
result: {
|
|
97
229
|
dashboard_auth_session_id: result.dashboard_auth_session_id,
|
|
230
|
+
stage: 'created',
|
|
98
231
|
expires_at: result.expires_at,
|
|
99
232
|
methods: ['phone', 'email', 'alipay', 'wechat'],
|
|
100
233
|
},
|
|
101
234
|
handoff: {
|
|
102
235
|
url: url.href,
|
|
236
|
+
contains_credentials: true,
|
|
103
237
|
...(qr ? { qr_local_path: qr.filePath, markdown: `` } : {}),
|
|
104
238
|
},
|
|
105
|
-
instruction: '把官方授权页或二维码交给用户。用户在页面内选择手机号验证码、邮箱或钱包完成登录;不要替用户输入手机号或验证码,start_token
|
|
239
|
+
instruction: '把官方授权页或二维码交给用户。用户在页面内选择手机号验证码、邮箱或钱包完成登录;不要替用户输入手机号或验证码,start_token 不要写入聊天记录或日志。',
|
|
106
240
|
next: { command: 'itpay auth status --json', reason: '用户完成页面登录后确认绑定', poll_after_ms: 5000 },
|
|
107
241
|
recovery: [],
|
|
108
242
|
};
|
|
@@ -119,36 +253,50 @@ async function accountAuth(action, baseURL, env, fetcher, backend) {
|
|
|
119
253
|
}
|
|
120
254
|
if (!backend && sellerSessionToken(baseURL, env))
|
|
121
255
|
return { status: 'authenticated', base_url: baseURL, expires_at: state?.expiresAt };
|
|
122
|
-
if (!state?.sessionID || !state.pollToken || !state.startToken)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
256
|
+
if (!state?.sessionID || !state.pollToken || !state.startToken) {
|
|
257
|
+
// AUTH07: a bare `status` with no session is its own truthful state, not a
|
|
258
|
+
// failure and not an implicit login.
|
|
259
|
+
if (!backend)
|
|
260
|
+
return { status: 'login_required' };
|
|
261
|
+
return {
|
|
262
|
+
status: 'login_required',
|
|
263
|
+
result: {},
|
|
264
|
+
instruction: '当前设备尚未绑定账号,也没有进行中的授权请求。需要时运行 itpay auth login 发起官方登录。',
|
|
265
|
+
next: { command: 'itpay auth login --json', reason: '需要账号绑定时发起官方授权' },
|
|
266
|
+
recovery: [],
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
let progress;
|
|
270
|
+
try {
|
|
271
|
+
progress = await pollOpenSession(state);
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
if (error instanceof AuthRequestError && (error.httpStatus === 404 || error.httpStatus === 410)) {
|
|
275
|
+
return sessionGone(env, purpose, baseURL, state.sessionID);
|
|
276
|
+
}
|
|
277
|
+
return authStatusUnknown(state.sessionID);
|
|
278
|
+
}
|
|
126
279
|
if (progress.status !== 'completed') {
|
|
127
280
|
if (backend) {
|
|
128
|
-
|
|
281
|
+
const terminal = TERMINAL_SESSION_STATES[progress.status];
|
|
282
|
+
if (terminal) {
|
|
129
283
|
rmSync(sellerAuthPath(baseURL, env, purpose), { force: true });
|
|
130
284
|
return {
|
|
131
|
-
status:
|
|
132
|
-
result: { dashboard_auth_session_id: state.sessionID },
|
|
133
|
-
instruction:
|
|
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 生成新请求。',
|
|
285
|
+
status: terminal.status,
|
|
286
|
+
result: { dashboard_auth_session_id: state.sessionID, stage: progress.status },
|
|
287
|
+
instruction: terminal.instruction,
|
|
144
288
|
next: { command: 'itpay auth login --json', reason: '重新发起官方授权' },
|
|
145
289
|
recovery: [],
|
|
146
290
|
};
|
|
147
291
|
}
|
|
148
292
|
return {
|
|
149
293
|
status: 'auth_pending',
|
|
150
|
-
result: {
|
|
151
|
-
|
|
294
|
+
result: {
|
|
295
|
+
dashboard_auth_session_id: state.sessionID,
|
|
296
|
+
stage: progress.status,
|
|
297
|
+
...(progress.expires_at ? { expires_at: progress.expires_at } : {}),
|
|
298
|
+
},
|
|
299
|
+
instruction: stageInstruction(progress.status),
|
|
152
300
|
next: { command: 'itpay auth status --json', reason: '轮询同一授权请求', poll_after_ms: 5000 },
|
|
153
301
|
recovery: [],
|
|
154
302
|
};
|
|
@@ -156,19 +304,18 @@ async function accountAuth(action, baseURL, env, fetcher, backend) {
|
|
|
156
304
|
return { status: progress.status, instruction: 'Finish login and email verification in the browser.' };
|
|
157
305
|
}
|
|
158
306
|
if (backend) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
};
|
|
307
|
+
try {
|
|
308
|
+
return await bindSavedSession(state);
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
if (error instanceof AuthRequestError)
|
|
312
|
+
throw error;
|
|
313
|
+
// Binding outcome unknown: keep the session so a retry can still bind
|
|
314
|
+
// the same completed login instead of creating a new one.
|
|
315
|
+
return authStatusUnknown(state.sessionID);
|
|
316
|
+
}
|
|
170
317
|
}
|
|
171
|
-
const claimed = await request(
|
|
318
|
+
const claimed = await request(`/v1/dashboard/auth-sessions/${encodeURIComponent(state.sessionID)}/claim?start_token=${encodeURIComponent(state.startToken)}`, { method: 'POST' });
|
|
172
319
|
const token = /(?:^|[, ]+)itpay_buyer_session=([^;]+)/.exec(claimed.headers.get('set-cookie') ?? '')?.[1];
|
|
173
320
|
const session = await claimed.json();
|
|
174
321
|
if (!token || !session.expires_at || !(Date.parse(session.expires_at) > Date.now()))
|
|
@@ -79,3 +79,73 @@ export function validateContext(host, target) {
|
|
|
79
79
|
}
|
|
80
80
|
return undefined;
|
|
81
81
|
}
|
|
82
|
+
export function normalizeViewer(raw) {
|
|
83
|
+
const v = raw?.trim().toLowerCase();
|
|
84
|
+
if (v === "desktop" || v === "mobile")
|
|
85
|
+
return v;
|
|
86
|
+
return "unknown";
|
|
87
|
+
}
|
|
88
|
+
export function normalizeLocality(raw) {
|
|
89
|
+
const v = raw?.trim().toLowerCase();
|
|
90
|
+
if (v === "same_device" || v === "remote" || v === "cloud")
|
|
91
|
+
return v;
|
|
92
|
+
return "unknown";
|
|
93
|
+
}
|
|
94
|
+
// Conservative declared capability table per host. Terminal/desktop agent
|
|
95
|
+
// hosts run where a human watches a screen and a system browser exists;
|
|
96
|
+
// IM hosts can send links/buttons but cannot be assumed to open pages;
|
|
97
|
+
// plain-chat surfaces get links and copyable entries only.
|
|
98
|
+
export function hostCapabilities(host) {
|
|
99
|
+
switch (host) {
|
|
100
|
+
case "terminal":
|
|
101
|
+
case "codex":
|
|
102
|
+
case "claude-code":
|
|
103
|
+
return {
|
|
104
|
+
user_visible_browser: "unknown",
|
|
105
|
+
system_browser: "yes",
|
|
106
|
+
clickable_https: "yes",
|
|
107
|
+
image_visible: host === "terminal" ? "no" : "unknown",
|
|
108
|
+
native_url_button: "no",
|
|
109
|
+
user_visible_terminal: "yes",
|
|
110
|
+
other_device_scan: "yes",
|
|
111
|
+
send_message: "yes",
|
|
112
|
+
};
|
|
113
|
+
case "telegram":
|
|
114
|
+
case "feishu":
|
|
115
|
+
case "lark":
|
|
116
|
+
return {
|
|
117
|
+
user_visible_browser: "no",
|
|
118
|
+
system_browser: "no",
|
|
119
|
+
clickable_https: "yes",
|
|
120
|
+
image_visible: "yes",
|
|
121
|
+
native_url_button: "yes",
|
|
122
|
+
user_visible_terminal: "no",
|
|
123
|
+
other_device_scan: "unknown",
|
|
124
|
+
send_message: "yes",
|
|
125
|
+
};
|
|
126
|
+
case "discord":
|
|
127
|
+
case "whatsapp":
|
|
128
|
+
return {
|
|
129
|
+
user_visible_browser: "no",
|
|
130
|
+
system_browser: "no",
|
|
131
|
+
clickable_https: "yes",
|
|
132
|
+
image_visible: "yes",
|
|
133
|
+
native_url_button: "unknown",
|
|
134
|
+
user_visible_terminal: "no",
|
|
135
|
+
other_device_scan: "unknown",
|
|
136
|
+
send_message: "yes",
|
|
137
|
+
};
|
|
138
|
+
case "plain-chat":
|
|
139
|
+
default:
|
|
140
|
+
return {
|
|
141
|
+
user_visible_browser: "no",
|
|
142
|
+
system_browser: "unknown",
|
|
143
|
+
clickable_https: "yes",
|
|
144
|
+
image_visible: "no",
|
|
145
|
+
native_url_button: "unknown",
|
|
146
|
+
user_visible_terminal: "no",
|
|
147
|
+
other_device_scan: "unknown",
|
|
148
|
+
send_message: "yes",
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
package/dist/src/state/config.js
CHANGED
|
@@ -15,11 +15,12 @@ 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.19";
|
|
19
|
+
export const API_CONTRACT_REVISION = "sha256:2f9fbcdc5cd0630910abb52671304cc019e348e9a778017ec92ddc3513b3639f";
|
|
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";
|
|
23
|
+
const TASK_JOURNAL_FILENAME = "tasks.json";
|
|
23
24
|
export function cliDistribution(env = process.env) {
|
|
24
25
|
if (env.ITPAY_DISTRIBUTION === "openclaw-skill-bundle")
|
|
25
26
|
return "openclaw-skill-bundle";
|
|
@@ -75,6 +76,16 @@ export function cartSessionPath(env = process.env) {
|
|
|
75
76
|
mkdirSync(dir, { recursive: true });
|
|
76
77
|
return resolve(dir, stateFilename(CART_SESSION_FILENAME, resolveBackendURL(env)));
|
|
77
78
|
}
|
|
79
|
+
// Task journal (AUTH04): paused Service Executions per backend, so a login
|
|
80
|
+
// completion can resume the SAME task instead of guessing from the latest order.
|
|
81
|
+
export function taskJournalPath(env = process.env) {
|
|
82
|
+
if (env.ITPAY_TASK_JOURNAL_PATH) {
|
|
83
|
+
return resolve(env.ITPAY_TASK_JOURNAL_PATH);
|
|
84
|
+
}
|
|
85
|
+
const dir = stateDir(env);
|
|
86
|
+
mkdirSync(dir, { recursive: true });
|
|
87
|
+
return resolve(dir, stateFilename(TASK_JOURNAL_FILENAME, resolveBackendURL(env)));
|
|
88
|
+
}
|
|
78
89
|
export function loadConfig(env = process.env) {
|
|
79
90
|
const baseURL = resolveBackendURL(env);
|
|
80
91
|
const bearerToken = env.ITPAY_BEARER_TOKEN || undefined;
|