@itpay/cli 2.1.12 → 2.1.18
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 +4 -0
- package/dist/src/commands/checkout.js +109 -2
- package/dist/src/commands/guidance.js +33 -1
- package/dist/src/commands/order.js +8 -1
- package/dist/src/commands/presentation.js +153 -0
- package/dist/src/commands/rail_catalog.js +112 -0
- package/dist/src/commands/services.js +388 -36
- package/dist/src/main.js +26 -1
- 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/task_journal.js +88 -0
- package/docs/agent/buyer/identity-and-sessions.json +9 -1
- package/docs/agent/buyer/rail-booking.json +16 -2
- package/docs/agent/buyer/rail-fast-checkout.json +67 -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 +55 -3
package/dist/src/main.js
CHANGED
|
@@ -311,7 +311,18 @@ for (const action of ["login", "status"]) {
|
|
|
311
311
|
const config = loadConfig();
|
|
312
312
|
try {
|
|
313
313
|
const result = await agentAuth(action, config.baseURL, newBackendClient(config));
|
|
314
|
-
|
|
314
|
+
// AUTH06: auth output goes through the same envelope renderer as every
|
|
315
|
+
// other command — next/recovery keep Agent Type + Backend qualification
|
|
316
|
+
// and non-JSON output is human-readable instead of a raw JSON dump.
|
|
317
|
+
const envelope = {
|
|
318
|
+
status: String(result.status ?? "error"),
|
|
319
|
+
result: (result.result ?? result),
|
|
320
|
+
...(result.handoff ? { handoff: result.handoff } : {}),
|
|
321
|
+
instruction: typeof result.instruction === "string" ? result.instruction : "按返回状态继续。",
|
|
322
|
+
next: (result.next ?? null),
|
|
323
|
+
recovery: (result.recovery ?? []),
|
|
324
|
+
};
|
|
325
|
+
writeCommandEnvelope(envelope, { jsonOutput: Boolean(options.json) });
|
|
315
326
|
}
|
|
316
327
|
catch (error) {
|
|
317
328
|
reportCLIError(error, { jsonOutput: Boolean(options.json), code: "account_login_failed", instruction: "完成官方网页登录后重试 itpay auth status;不要清除设备登记。" });
|
|
@@ -893,6 +904,13 @@ program
|
|
|
893
904
|
.option("--id <checkout_id>")
|
|
894
905
|
.option("--token <display_token>")
|
|
895
906
|
.option("--locale <locale>", "payment card language: zh-CN|en", "zh-CN")
|
|
907
|
+
.option("--present <method>", "display once: auto|browser|image|link|none (JSON default plans only)")
|
|
908
|
+
.option("--no-open", "never open a browser window")
|
|
909
|
+
.option("--viewer <viewer>", "device the human is looking at: desktop|mobile|unknown")
|
|
910
|
+
.option("--relay-option <ref>", "server-issued contact option reference for message relay")
|
|
911
|
+
.option("--confirm-relay", "confirm the human explicitly consented to this relay send")
|
|
912
|
+
.option("--request-key <key>", "stable idempotency key for a relay request")
|
|
913
|
+
.option("--relay-status <relay_id>", "read a prior relay result; never resends")
|
|
896
914
|
.option("--json", "output compact JSON")
|
|
897
915
|
.action(async (options) => {
|
|
898
916
|
const config = loadConfig();
|
|
@@ -919,6 +937,13 @@ program
|
|
|
919
937
|
baseURL: config.baseURL,
|
|
920
938
|
locale: options.locale,
|
|
921
939
|
jsonOutput: Boolean(options.json),
|
|
940
|
+
...(options.present ? { present: options.present } : {}),
|
|
941
|
+
...(options.noOpen ? { noOpen: true } : {}),
|
|
942
|
+
...(options.viewer ? { viewer: options.viewer } : {}),
|
|
943
|
+
...(options.relayOption ? { relayOption: options.relayOption } : {}),
|
|
944
|
+
...(options.confirmRelay ? { confirmRelay: true } : {}),
|
|
945
|
+
...(options.requestKey ? { requestKey: options.requestKey } : {}),
|
|
946
|
+
...(options.relayStatus ? { relayStatus: options.relayStatus } : {}),
|
|
922
947
|
});
|
|
923
948
|
}
|
|
924
949
|
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.18";
|
|
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;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Minimal local task journal: which Service Executions are paused at a human
|
|
2
|
+
// gate (login, rate limit) and how to resume the SAME execution. It stores
|
|
3
|
+
// no chat history, PII, credentials or tokens — only ids, stage and the
|
|
4
|
+
// resume command the owner flow already emitted. Business truth stays
|
|
5
|
+
// server-side; this file only makes "continue" point at the right task.
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
export const PAUSED_STAGES = new Set([
|
|
9
|
+
"login_required",
|
|
10
|
+
"rate_limited",
|
|
11
|
+
"quota_paused",
|
|
12
|
+
"auth_pending",
|
|
13
|
+
"awaiting_input",
|
|
14
|
+
]);
|
|
15
|
+
const TERMINAL_STAGES = new Set([
|
|
16
|
+
"completed",
|
|
17
|
+
"delivered",
|
|
18
|
+
"issued",
|
|
19
|
+
"failed",
|
|
20
|
+
"cancelled",
|
|
21
|
+
"refunded",
|
|
22
|
+
]);
|
|
23
|
+
export class TaskJournal {
|
|
24
|
+
path;
|
|
25
|
+
constructor(path) {
|
|
26
|
+
this.path = path;
|
|
27
|
+
}
|
|
28
|
+
record(task) {
|
|
29
|
+
const file = this.readFile();
|
|
30
|
+
file.tasks[task.service_execution_id] = {
|
|
31
|
+
...task,
|
|
32
|
+
updated_at: new Date().toISOString(),
|
|
33
|
+
};
|
|
34
|
+
this.writeFile(file);
|
|
35
|
+
}
|
|
36
|
+
clear(serviceExecutionID) {
|
|
37
|
+
const file = this.readFile();
|
|
38
|
+
if (!file.tasks[serviceExecutionID])
|
|
39
|
+
return;
|
|
40
|
+
delete file.tasks[serviceExecutionID];
|
|
41
|
+
this.writeFile(file);
|
|
42
|
+
}
|
|
43
|
+
// Observe the latest stage without deleting history; terminal stages stop
|
|
44
|
+
// the task from counting as resumable.
|
|
45
|
+
observe(serviceExecutionID, stage, resumeCommand) {
|
|
46
|
+
const file = this.readFile();
|
|
47
|
+
const existing = file.tasks[serviceExecutionID];
|
|
48
|
+
file.tasks[serviceExecutionID] = {
|
|
49
|
+
service_execution_id: serviceExecutionID,
|
|
50
|
+
...(existing?.service_id ? { service_id: existing.service_id } : {}),
|
|
51
|
+
stage,
|
|
52
|
+
...(resumeCommand ?? existing?.resume_command ? { resume_command: resumeCommand ?? existing.resume_command } : {}),
|
|
53
|
+
updated_at: new Date().toISOString(),
|
|
54
|
+
};
|
|
55
|
+
this.writeFile(file);
|
|
56
|
+
}
|
|
57
|
+
// Tasks paused behind login/quota gates, newest first. Terminal or
|
|
58
|
+
// completed tasks never count as "still waiting for login".
|
|
59
|
+
pausedTasks() {
|
|
60
|
+
const file = this.readFile();
|
|
61
|
+
return Object.values(file.tasks)
|
|
62
|
+
.filter((task) => PAUSED_STAGES.has(task.stage) && !TERMINAL_STAGES.has(task.stage))
|
|
63
|
+
.sort((left, right) => right.updated_at.localeCompare(left.updated_at));
|
|
64
|
+
}
|
|
65
|
+
readFile() {
|
|
66
|
+
if (!existsSync(this.path))
|
|
67
|
+
return { schema_version: "itpay.task_journal.v1", tasks: {} };
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(readFileSync(this.path, "utf8"));
|
|
70
|
+
if (parsed.schema_version !== "itpay.task_journal.v1" || !parsed.tasks || typeof parsed.tasks !== "object") {
|
|
71
|
+
return { schema_version: "itpay.task_journal.v1", tasks: {} };
|
|
72
|
+
}
|
|
73
|
+
return parsed;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return { schema_version: "itpay.task_journal.v1", tasks: {} };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
writeFile(file) {
|
|
80
|
+
// Bound the journal: keep only the 50 most recent task records.
|
|
81
|
+
const entries = Object.entries(file.tasks).sort((left, right) => right[1].updated_at.localeCompare(left[1].updated_at));
|
|
82
|
+
file.tasks = Object.fromEntries(entries.slice(0, 50));
|
|
83
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
84
|
+
const tmp = `${this.path}.${process.pid}.tmp`;
|
|
85
|
+
writeFileSync(tmp, JSON.stringify(file), { mode: 0o600 });
|
|
86
|
+
renameSync(tmp, this.path);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -90,5 +90,13 @@
|
|
|
90
90
|
"lock",
|
|
91
91
|
"window",
|
|
92
92
|
"chat"
|
|
93
|
-
]
|
|
93
|
+
],
|
|
94
|
+
"auth_session_states": {
|
|
95
|
+
"stages": "The CLI now reports the auth session stage exactly as the server returns it: waiting_provider (show the handoff and wait), email_verification_required (the human must verify the emailed code on the auth page), merge_confirmation_required (an existing account matches — the human must confirm the merge on the auth page), provider_pending/pending (generic wait), completed (session done — bind it, do not restart).",
|
|
96
|
+
"terminal_states": "auth_expired/auth_denied/auth_cancelled/auth_failed mean this session is over; say so plainly and start a NEW session only if the human still wants in — never keep polling a dead one.",
|
|
97
|
+
"transport_unknown": "auth_session_unknown means a network error while polling — the outcome is unknown. Keep waiting on the SAME session (it may still complete); do NOT restart auth, and do not assume denial.",
|
|
98
|
+
"missing": "auth_session_not_found means the cached session is gone server-side; start a new login.",
|
|
99
|
+
"completed_resume": "When login completes, itpay auth login resumes the remembered pending service execution automatically when one exists — you do not need to find it yourself.",
|
|
100
|
+
"path_b": "A buyer who reaches checkout without a login completes identity inside the protected checkout page; never force a separate itpay auth login first."
|
|
101
|
+
}
|
|
94
102
|
}
|