@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/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
- : error instanceof HttpError && error.code === "agent_device_session_required"
160
- ? "CLI 已自动续期并重试同一请求一次,仍被拒绝;停止重试,不要切换 Agent Type 或旋转身份。"
161
- : deviceError?.code === "agent_device_revoked"
162
- ? "Backend 已撤销当前 Device 登记;CLI 没有自动创建替代身份。停止重试并请用户或运营恢复登记。"
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;
170
- if (contract || commandError || backendOverrideError) {
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
- process.stdout.write(JSON.stringify(result) + "\n");
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
- const current = await backend.agentAccountStatus();
40
- if (current.status === 'authenticated')
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
- };
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 Error(`ItPay authorization failed (${response.status}); retry login if expired`);
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 (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)) {
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: { dashboard_auth_session_id: open.sessionID, expires_at: current.expires_at, methods: ['phone', 'email', 'alipay', 'wechat'] },
67
- handoff: { url, ...(qr ? { qr_local_path: qr.filePath, markdown: `![ItPay 官方授权二维码](${qr.filePath})` } : {}) },
68
- instruction: '已有进行中的官方授权请求,沿用同一链接或二维码;不要生成新请求。用户在页面内选择手机号验证码、邮箱或钱包完成登录。',
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: `![ItPay 官方授权二维码](${qr.filePath})` } : {}),
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: `![ItPay 官方授权二维码](${qr.filePath})` } : {}),
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
- return { status: 'login_required' };
124
- const path = `/v1/dashboard/auth-sessions/${encodeURIComponent(state.sessionID)}`;
125
- const progress = await (await request(`${path}?poll_token=${encodeURIComponent(state.pollToken)}`)).json();
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
- if (progress.status === 'expired') {
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: '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 生成新请求。',
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: { dashboard_auth_session_id: state.sessionID },
151
- instruction: '用户仍在官方页面完成登录;保留当前 handoff,不要生成新二维码或新请求。',
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
- const result = await backend.bindAgentAccount({ dashboard_auth_session_id: state.sessionID, start_token: state.startToken });
160
- if (result.status !== 'authenticated')
161
- throw new Error('Agent binding did not complete');
162
- rmSync(sellerAuthPath(baseURL, env, purpose), { force: true });
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
- };
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(`${path}/claim?start_token=${encodeURIComponent(state.startToken)}`, { method: 'POST' });
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
+ }
@@ -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.17";
19
- export const API_CONTRACT_REVISION = "sha256:c6125896053540d1b56f9d106df499ac90162fb8fa4cce619c8029e90f771f85";
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;