@xiashe/sdk 0.1.11 → 0.1.13

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/alipayWallet.mjs CHANGED
@@ -1,9 +1,15 @@
1
- import { createHash } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { execFile } from 'node:child_process';
3
+ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
3
6
 
4
7
  const DEFAULT_TIMEOUT_MS = 8 * 60 * 1000;
8
+ const RESOURCE_FETCH_TIMEOUT_MS = 20 * 1000;
5
9
  const MAX_OUTPUT_BYTES = 32 * 1024;
6
10
  const SENSITIVE_OUTPUT = /payment[-_ ]?proof|payment[-_ ]?needed|seller_signature|runtimeToken|authorization\s*:/i;
11
+ const SENSITIVE_USER_OUTPUT = /payment[-_ ]?proof|payment[-_ ]?needed|seller_signature|runtimeToken|authorization\s*:|trade[_ -]?no|交易(?:流水)?号|out[_ -]?shake|client[_ -]?session/i;
12
+ const DEFAULT_STATE_DIR = path.join(os.homedir(), '.xiashe', 'alipay-402');
7
13
 
8
14
  export class AlipayWalletError extends Error {
9
15
  constructor(code, message, options = {}) {
@@ -19,14 +25,14 @@ function safeText(value, maxLength = 240) {
19
25
  }
20
26
 
21
27
  function digest(value) {
22
- return createHash('sha256').update(String(value || '')).digest('hex').slice(0, 16);
28
+ return createHash('sha256').update(String(value || '')).digest('hex');
23
29
  }
24
30
 
25
31
  function safeDiagnostic(value) {
26
32
  const text = safeText(value, MAX_OUTPUT_BYTES);
27
33
  if (!text) return 'no_wallet_output';
28
- // CLI output is not a protocol API: never forward it to an Agent. This
29
- // protects future vendor versions even when they add fields we do not know.
34
+ // Vendor output is not a stable public API. Never forward it because a new
35
+ // version could include a protocol proof or another credential.
30
36
  return SENSITIVE_OUTPUT.test(text) ? 'protocol_payload_processed' : 'official_wallet_response_received';
31
37
  }
32
38
 
@@ -51,6 +57,48 @@ function normalizeIntentSummary(value) {
51
57
  return summary;
52
58
  }
53
59
 
60
+ function stateDirectory(options = {}) {
61
+ return path.resolve(options.stateDir || process.env.XIASHE_ALIPAY_PAYMENT_STATE_DIR || DEFAULT_STATE_DIR);
62
+ }
63
+
64
+ function statePaths(resourceUrl, options = {}) {
65
+ const base = digest(resourceUrl);
66
+ const directory = stateDirectory(options);
67
+ return {
68
+ directory,
69
+ paymentNeededPath: path.join(directory, `${base}.txt`),
70
+ statePath: path.join(directory, `${base}.json`)
71
+ };
72
+ }
73
+
74
+ async function ensurePrivateDirectory(directory) {
75
+ await mkdir(directory, { recursive: true, mode: 0o700 });
76
+ // mkdir does not change permissions of an existing directory.
77
+ await chmod(directory, 0o700);
78
+ }
79
+
80
+ async function writePrivateFile(filePath, contents) {
81
+ await writeFile(filePath, contents, { encoding: 'utf8', mode: 0o600 });
82
+ await chmod(filePath, 0o600);
83
+ }
84
+
85
+ async function readPaymentState(resourceUrl, options = {}) {
86
+ const { statePath } = statePaths(resourceUrl, options);
87
+ try {
88
+ const parsed = JSON.parse(await readFile(statePath, 'utf8'));
89
+ return parsed && parsed.version === 1 && parsed.resourceDigest === digest(resourceUrl) ? parsed : null;
90
+ } catch (error) {
91
+ if (error?.code === 'ENOENT') return null;
92
+ throw new AlipayWalletError('ALIPAY_PAYMENT_STATE_INVALID', '本地支付宝付款状态无法读取;请重新发起本次付款。');
93
+ }
94
+ }
95
+
96
+ async function writePaymentState(state, options = {}) {
97
+ const { directory, statePath } = statePaths(state.resourceUrl, options);
98
+ await ensurePrivateDirectory(directory);
99
+ await writePrivateFile(statePath, `${JSON.stringify(state)}\n`);
100
+ }
101
+
54
102
  function defaultRunFile(command, args, options) {
55
103
  return new Promise((resolve, reject) => {
56
104
  execFile(command, args, {
@@ -71,8 +119,12 @@ function defaultRunFile(command, args, options) {
71
119
  }
72
120
 
73
121
  function normalizedRunnerResult(result) {
74
- if (typeof result === 'string') return { stdout: result, stderr: '' };
75
- return { stdout: String(result?.stdout || ''), stderr: String(result?.stderr || '') };
122
+ if (typeof result === 'string') return { stdout: result, stderr: '', exitCode: 0 };
123
+ return {
124
+ stdout: String(result?.stdout || ''),
125
+ stderr: String(result?.stderr || ''),
126
+ exitCode: Number.isInteger(result?.exitCode) ? result.exitCode : 0
127
+ };
76
128
  }
77
129
 
78
130
  async function runOfficialCli(args, options = {}) {
@@ -91,9 +143,19 @@ async function runOfficialCli(args, options = {}) {
91
143
  throw new AlipayWalletError('ALIPAY_BOT_NOT_INSTALLED', '未找到支付宝官方钱包工具。请先在当前 Agent 环境安装并授权 @alipay/agent-payment。');
92
144
  }
93
145
  if (error?.killed || error?.signal === 'SIGTERM') {
94
- throw new AlipayWalletError('ALIPAY_WALLET_TIMEOUT', '支付宝钱包等待确认超时;不要据此判断付款失败,请查询订单状态。');
146
+ throw new AlipayWalletError('ALIPAY_WALLET_TIMEOUT', '支付宝钱包等待确认超时;请用同一支付命令的 status 查询状态。');
95
147
  }
96
- throw new AlipayWalletError('ALIPAY_WALLET_COMMAND_FAILED', '支付宝钱包未完成该操作。');
148
+ // The official buyer CLI can use a non-zero process exit for a user-action
149
+ // state such as “payment pending”. Preserve this invocation's output for
150
+ // the caller to parse before classifying it as a real command failure.
151
+ if (Number.isInteger(error?.code)) {
152
+ return {
153
+ stdout: String(error.stdout || ''),
154
+ stderr: String(error.stderr || ''),
155
+ exitCode: error.code
156
+ };
157
+ }
158
+ throw new AlipayWalletError('ALIPAY_WALLET_COMMAND_FAILED', '支付宝官方钱包未完成该操作。');
97
159
  }
98
160
  }
99
161
 
@@ -104,10 +166,101 @@ function walletStateFromOutput(stdout, stderr) {
104
166
  return enabled ? 'ready' : unavailable ? 'unavailable' : 'unknown';
105
167
  }
106
168
 
107
- /**
108
- * Runs only the official local wallet client. It never returns Payment-Needed,
109
- * Payment-Proof, or raw wallet output to an Agent caller.
110
- */
169
+ function queryCredentialFromOutput(output) {
170
+ const tradeNo = output.match(/(?:"tradeNo"|trade\s*no|交易(?:流水)?号)\s*["::=]\s*["']?([A-Za-z0-9_-]{8,128})/i)?.[1] || null;
171
+ const outShakeNo = output.match(/(?:"outShakeNo"|out[_ -]?shake[_ -]?no|外部(?:摇一摇)?编号)\s*["::=]\s*["']?([A-Za-z0-9_-]{8,128})/i)?.[1] || null;
172
+ return tradeNo ? { type: 'trade_no', value: tradeNo } : outShakeNo ? { type: 'out_shake_no', value: outShakeNo } : null;
173
+ }
174
+
175
+ function paymentStatusFromOutput(stdout, stderr) {
176
+ const output = `${stdout}\n${stderr}`;
177
+ const completed = /支付(?:已)?成功|交易成功|支付已完成|payment\s+(?:was\s+)?(?:successful|completed)|(?:resource|资源).*(?:obtained|success|成功)/i.test(output);
178
+ const pending = /支付待确认|等待.*确认|待确认|支付处理中|pending(?:\s+confirmation)?|waiting.*confirm/i.test(output);
179
+ const failed = /支付失败|交易失败|payment\s+failed/i.test(output);
180
+ return {
181
+ status: completed ? 'completed' : pending ? 'pending_confirmation' : failed ? 'failed' : 'unknown',
182
+ credential: queryCredentialFromOutput(output),
183
+ diagnostic: safeDiagnostic(output)
184
+ };
185
+ }
186
+
187
+ function userVisibleWalletOutput(stdout, stderr) {
188
+ const lines = `${stdout}\n${stderr}`
189
+ .replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, '')
190
+ .split(/\r?\n/)
191
+ .map((line) => line.trim())
192
+ .filter((line) => line && !SENSITIVE_USER_OUTPUT.test(line))
193
+ .slice(0, 12);
194
+ return lines.length ? lines.join('\n').slice(0, 1_200) : null;
195
+ }
196
+
197
+ function walletFailureCode(stdout, stderr, exitCode) {
198
+ const output = `${stdout}\n${stderr}`;
199
+ if (/余额不足|insufficient\s+balance/i.test(output)) return 'insufficient_balance';
200
+ if (/已过期|过期|expired/i.test(output)) return 'payment_expired';
201
+ if (/钱包.*(?:未开启|未授权)|wallet.*(?:not\s+enabled|not\s+authorized)/i.test(output)) return 'wallet_not_ready';
202
+ if (/买家.*(?:不匹配|校验失败)|buyer.*(?:mismatch|identity)/i.test(output)) return 'buyer_identity_rejected';
203
+ if (/账单.*(?:无效|签名)|签名.*(?:无效|失败)|bill.*(?:invalid|signature)/i.test(output)) return 'payment_needed_rejected';
204
+ if (/network|网络|timeout|超时/i.test(output)) return 'wallet_network_error';
205
+ return Number.isInteger(exitCode) && exitCode !== 0 ? 'official_cli_nonzero_exit' : 'official_cli_unclassified';
206
+ }
207
+
208
+ function paymentMessage(status) {
209
+ if (status === 'completed') return '支付宝钱包显示本次付款已完成;正在由服务端确认权益。';
210
+ if (status === 'pending_confirmation') return '支付宝已发起本次付款确认;请打开支付宝 App 完成确认,然后运行 status。';
211
+ if (status === 'failed') return '支付宝钱包显示本次付款未完成;未授予付费 Skill 权益。';
212
+ return '支付宝官方钱包已被调用,但尚未返回可确认的付款状态;请运行 status 查询,或重新发起付款。';
213
+ }
214
+
215
+ async function fetchPaymentNeeded(resourceUrl, options = {}) {
216
+ const fetchFn = options.fetch || globalThis.fetch;
217
+ const controller = new AbortController();
218
+ const timeout = setTimeout(() => controller.abort(), options.resourceFetchTimeoutMs || RESOURCE_FETCH_TIMEOUT_MS);
219
+ try {
220
+ const response = await fetchFn(resourceUrl, {
221
+ method: 'GET',
222
+ redirect: 'error',
223
+ headers: { Accept: 'application/json' },
224
+ signal: controller.signal
225
+ });
226
+ if (response.status === 200) return { alreadyPaid: true };
227
+ if (response.status !== 402) {
228
+ throw new AlipayWalletError('ALIPAY_RESOURCE_PAYMENT_NEEDED_FAILED', `支付资源未返回付款请求(HTTP ${response.status})。`, { status: response.status });
229
+ }
230
+ const paymentNeeded = response.headers.get('Payment-Needed');
231
+ if (!paymentNeeded) {
232
+ throw new AlipayWalletError('ALIPAY_RESOURCE_PAYMENT_NEEDED_MISSING', '支付资源未返回有效的支付宝付款请求。');
233
+ }
234
+ return { paymentNeeded };
235
+ } catch (error) {
236
+ if (error instanceof AlipayWalletError) throw error;
237
+ if (error?.name === 'AbortError') {
238
+ throw new AlipayWalletError('ALIPAY_RESOURCE_TIMEOUT', '请求支付宝支付资源超时,请重试。');
239
+ }
240
+ throw new AlipayWalletError('ALIPAY_RESOURCE_REQUEST_FAILED', '无法请求支付宝支付资源,请检查网络后重试。');
241
+ } finally {
242
+ clearTimeout(timeout);
243
+ }
244
+ }
245
+
246
+ function publicPaymentResult(state, status, diagnostic, options = {}) {
247
+ const commandFailed = options.commandFailed === true;
248
+ return {
249
+ ok: status !== 'failed' && status !== 'wallet_command_failed',
250
+ status,
251
+ provider: 'alipay-agent-payment',
252
+ attemptId: `alipay-402:${state.resourceDigest.slice(0, 16)}`,
253
+ walletInvoked: true,
254
+ message: status === 'wallet_command_failed'
255
+ ? '支付宝官方钱包没有生成可确认的付款状态;未授予付费 Skill 权益。'
256
+ : paymentMessage(status),
257
+ diagnostic,
258
+ ...(options.userVisible ? { userVisible: options.userVisible } : {}),
259
+ ...(commandFailed ? { failureCode: options.failureCode || 'official_cli_nonzero_exit' } : {})
260
+ };
261
+ }
262
+
263
+ /** Runs only the official local wallet client and never exposes its raw output. */
111
264
  export async function checkAlipayWallet(options = {}) {
112
265
  const { stdout, stderr } = await runOfficialCli(['check-wallet'], options);
113
266
  const state = walletStateFromOutput(stdout, stderr);
@@ -120,9 +273,23 @@ export async function checkAlipayWallet(options = {}) {
120
273
  };
121
274
  }
122
275
 
276
+ /** Checks that the official buyer CLI is present, without querying wallet state. */
277
+ export async function getAlipayWalletBridgeInfo(options = {}) {
278
+ const { stdout, stderr } = await runOfficialCli(['--version'], options);
279
+ return {
280
+ ok: true,
281
+ state: 'available',
282
+ provider: 'alipay-agent-payment',
283
+ message: '支付宝官方付款桥已可用;付款时将由支付宝 App 请求确认。',
284
+ diagnostic: safeDiagnostic(stdout || stderr)
285
+ };
286
+ }
287
+
123
288
  /**
124
- * Starts one explicit buyer-side 402 payment. The official CLI owns the
125
- * protocol payload and App confirmation; callers only receive a safe result.
289
+ * Starts a buyer-side 402 payment according to the official sequence:
290
+ * request the resource once, preserve its exact Payment-Needed header only in
291
+ * a private local file, then invoke the official buyer CLI with that file and
292
+ * a stable session id. No protocol data leaves this module.
126
293
  */
127
294
  export async function payAlipay402Resource(input, options = {}) {
128
295
  if (input?.confirm !== true) {
@@ -130,36 +297,98 @@ export async function payAlipay402Resource(input, options = {}) {
130
297
  }
131
298
  const resourceUrl = normalizeResourceUrl(input?.resourceUrl);
132
299
  const intentSummary = normalizeIntentSummary(input?.intentSummary);
133
- const wallet = await checkAlipayWallet(options);
134
- if (!wallet.ok) {
135
- throw new AlipayWalletError('ALIPAY_WALLET_NOT_READY', '支付宝钱包未开启;请先完成钱包授权。');
300
+ const resource = await fetchPaymentNeeded(resourceUrl, options);
301
+ if (resource.alreadyPaid) {
302
+ const state = (await readPaymentState(resourceUrl, options)) || {
303
+ version: 1,
304
+ resourceUrl,
305
+ resourceDigest: digest(resourceUrl),
306
+ sessionId: null,
307
+ paymentNeededPath: null,
308
+ createdAt: new Date().toISOString(),
309
+ updatedAt: new Date().toISOString()
310
+ };
311
+ return publicPaymentResult(state, 'completed', 'resource_already_available');
136
312
  }
137
- const { stdout, stderr } = await runOfficialCli([
313
+
314
+ const paths = statePaths(resourceUrl, options);
315
+ await ensurePrivateDirectory(paths.directory);
316
+ await writePrivateFile(paths.paymentNeededPath, resource.paymentNeeded);
317
+ const state = {
318
+ version: 1,
319
+ resourceUrl,
320
+ resourceDigest: digest(resourceUrl),
321
+ sessionId: randomUUID(),
322
+ paymentNeededPath: paths.paymentNeededPath,
323
+ queryCredential: null,
324
+ status: 'started',
325
+ createdAt: new Date().toISOString(),
326
+ updatedAt: new Date().toISOString()
327
+ };
328
+ await writePaymentState(state, options);
329
+
330
+ const walletInvocation = await runOfficialCli([
138
331
  '402-buyer-pay',
332
+ '--session-id', state.sessionId,
333
+ '--file', state.paymentNeededPath,
139
334
  '--resource-url', resourceUrl,
140
335
  '--intent-summary', intentSummary
141
336
  ], options);
142
- return {
143
- ok: true,
144
- status: 'submitted',
145
- provider: 'alipay-agent-payment',
146
- attemptId: `alipay-402:${digest(`${resourceUrl}\n${intentSummary}`)}`,
147
- message: '已交给支付宝钱包处理;请在支付宝 App 中确认本次付款。',
148
- diagnostic: safeDiagnostic(stdout || stderr)
149
- };
337
+ const result = paymentStatusFromOutput(walletInvocation.stdout, walletInvocation.stderr);
338
+ const commandFailed = walletInvocation.exitCode !== 0 && result.status === 'unknown';
339
+ const status = commandFailed ? 'wallet_command_failed' : result.status;
340
+ state.status = status;
341
+ state.queryCredential = result.credential;
342
+ state.updatedAt = new Date().toISOString();
343
+ await writePaymentState(state, options);
344
+ if (status === 'completed') await rm(paths.paymentNeededPath, { force: true });
345
+ return publicPaymentResult(state, status, result.diagnostic, {
346
+ commandFailed,
347
+ failureCode: commandFailed ? walletFailureCode(walletInvocation.stdout, walletInvocation.stderr, walletInvocation.exitCode) : undefined,
348
+ userVisible: ['pending_confirmation', 'completed', 'failed'].includes(status)
349
+ ? userVisibleWalletOutput(walletInvocation.stdout, walletInvocation.stderr)
350
+ : null
351
+ });
150
352
  }
151
353
 
354
+ /** Queries an existing local payment attempt using the official query credential. */
152
355
  export async function queryAlipay402Resource(input, options = {}) {
153
356
  const resourceUrl = normalizeResourceUrl(input?.resourceUrl);
154
- const { stdout, stderr } = await runOfficialCli(['402-query-payment-status', '--resource-url', resourceUrl], options);
155
- const output = `${stdout}\n${stderr}`;
156
- const complete = /success|成功|paid|已支付|completed|完成/i.test(output) && !/pending|等待|处理中/i.test(output);
157
- const pending = /pending|等待|处理中|confirm/i.test(output);
158
- return {
159
- ok: true,
160
- status: complete ? 'completed' : pending ? 'pending_confirmation' : 'unknown',
161
- provider: 'alipay-agent-payment',
162
- message: complete ? '支付宝钱包显示付款已完成。' : pending ? '等待用户在支付宝 App 中确认。' : '支付宝钱包尚未返回可确认的付款状态。',
163
- diagnostic: safeDiagnostic(stdout || stderr)
164
- };
357
+ const state = await readPaymentState(resourceUrl, options);
358
+ if (!state?.queryCredential?.value) {
359
+ return {
360
+ ok: false,
361
+ status: 'no_active_payment',
362
+ provider: 'alipay-agent-payment',
363
+ walletInvoked: false,
364
+ message: '没有可查询的本地支付宝付款记录;请重新发起付款,不要把付款凭证粘贴到对话中。',
365
+ diagnostic: 'payment_state_not_found'
366
+ };
367
+ }
368
+ const queryArg = state.queryCredential.type === 'out_shake_no'
369
+ ? ['--out-shake-no', state.queryCredential.value]
370
+ : ['-t', state.queryCredential.value];
371
+ const walletInvocation = await runOfficialCli([
372
+ '402-query-payment-status',
373
+ ...queryArg,
374
+ '--resource-url', resourceUrl
375
+ ], options);
376
+ const result = paymentStatusFromOutput(walletInvocation.stdout, walletInvocation.stderr);
377
+ const commandFailed = walletInvocation.exitCode !== 0 && result.status === 'unknown';
378
+ const status = commandFailed ? 'wallet_command_failed' : result.status;
379
+ state.status = status;
380
+ state.queryCredential = result.credential || state.queryCredential;
381
+ state.updatedAt = new Date().toISOString();
382
+ await writePaymentState(state, options);
383
+ if (status === 'completed') {
384
+ const { paymentNeededPath } = statePaths(resourceUrl, options);
385
+ await rm(paymentNeededPath, { force: true });
386
+ }
387
+ return publicPaymentResult(state, status, result.diagnostic, {
388
+ commandFailed,
389
+ failureCode: commandFailed ? walletFailureCode(walletInvocation.stdout, walletInvocation.stderr, walletInvocation.exitCode) : undefined,
390
+ userVisible: ['pending_confirmation', 'completed', 'failed'].includes(status)
391
+ ? userVisibleWalletOutput(walletInvocation.stdout, walletInvocation.stderr)
392
+ : null
393
+ });
165
394
  }
package/index.mjs CHANGED
@@ -6,7 +6,7 @@ import path from 'node:path';
6
6
  import { ConvexHttpClient } from 'convex/browser';
7
7
  import { anyApi as api } from 'convex/server';
8
8
 
9
- export const VERSION = '0.1.11';
9
+ export const VERSION = '0.1.13';
10
10
 
11
11
  export const DEFAULTS = {
12
12
  cn: {
@@ -1361,6 +1361,7 @@ export function stableTaskId() {
1361
1361
  export {
1362
1362
  AlipayWalletError,
1363
1363
  checkAlipayWallet,
1364
+ getAlipayWalletBridgeInfo,
1364
1365
  payAlipay402Resource,
1365
1366
  queryAlipay402Resource
1366
1367
  } from './alipayWallet.mjs';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiashe/sdk",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "type": "module",
5
5
  "main": "index.mjs",
6
6
  "exports": {