@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/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
- process.stdout.write(JSON.stringify(result) + "\n");
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
- 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.12";
19
- export const API_CONTRACT_REVISION = "sha256:2b3194239eec3f174ca329c3d9576afca4b81f3e4b6bdf073e5953f72875e153";
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
  }