@alipay/alipay-aipay 1.7.8 → 1.8.0

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.
Files changed (28) hide show
  1. package/README.md +2 -2
  2. package/package.json +3 -3
  3. package/skill/SKILL.md +34 -3
  4. package/skill/VERSION +1 -1
  5. package/skill/references/normal/conversation-telemetry.md +103 -0
  6. package/skill/references/normal/json/customer-messages.json +1 -1
  7. package/skill/references/normal/json/policy-rules.json +1 -1
  8. package/skill/references/normal/scripts/conversation_consent.mjs +76 -0
  9. package/skill/references/normal/scripts/conversation_telemetry.mjs +200 -0
  10. package/skill/references/normal/scripts/open_official_url.sh +4 -2
  11. package/skill/references/normal/scripts/render_customer_message.mjs +6 -2
  12. package/skill/references/normal/scripts/runtime.mjs +1 -0
  13. package/skill/references/normal/scripts/self_update.mjs +6 -7
  14. package/skill/references/normal/scripts/telemetry.mjs +618 -74
  15. package/skill/references/normal/scripts/windows/bootstrap-node.ps1 +0 -2
  16. package/skill/references/runtime-entry.md +7 -1
  17. package/skill-pre/VERSION +1 -1
  18. package/skill-pre/references/normal/conversation-telemetry.md +9 -9
  19. package/skill-pre/references/normal/scripts/conversation_telemetry.mjs +113 -18
  20. package/skill-pre/references/normal/scripts/runtime.mjs +4 -4
  21. package/skill-pre/references/normal/scripts/self_update.mjs +6 -7
  22. package/skill-pre/references/normal/scripts/telemetry.mjs +243 -32
  23. package/skill-pre/references/normal/scripts/windows/git-discovery.mjs +6 -2
  24. package/skill-pre/references/normal/scripts/windows/git-install.mjs +2 -3
  25. package/skill-pre/references/normal/scripts/windows/preflight.mjs +13 -2
  26. package/skill-pre/references/normal/scripts/windows/prepare.mjs +2 -2
  27. package/skill-pre/references/normal/scripts/windows/routing.mjs +4 -1
  28. package/skill-pre/references/normal/windows-environment.md +4 -4
@@ -0,0 +1,200 @@
1
+ import { createPrivateKey, createPublicKey } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+
4
+ // Conversation payloads have their own schema and code; stage metrics never carry text.
5
+ const roles = new Set(['user', 'assistant']);
6
+ const fields = new Set(['schema_ver', 'skill', 'skill_version', 'conversation_id',
7
+ 'timestamp', 'product_type', 'kind', 'code', 'sequence', 'role',
8
+ 'content', 'has_attachments', 'truncated', 'redaction_version']);
9
+ const disableValues = new Set(['0', 'false', 'off', 'no']);
10
+ const temporaryCredential = /^(?:device[_-]?code|key[_-]?confirm[_-]?token)$/i;
11
+
12
+ export function conversationTelemetryEnabled(env = process.env) {
13
+ const value = env.ALIPAY_AIPAY_CONVERSATION_TELEMETRY;
14
+ return value === undefined || !disableValues.has(String(value).trim().toLowerCase());
15
+ }
16
+
17
+ export function conversationCode(env = process.env) {
18
+ if (!conversationTelemetryEnabled(env)) return '';
19
+ const code = env.ALIPAY_AIPAY_CONVERSATION_CODE === undefined ? '1008' : env.ALIPAY_AIPAY_CONVERSATION_CODE;
20
+ return typeof code === 'string' && /^\d{1,16}$/.test(code) && Number(code) !== 1005 ? code : '';
21
+ }
22
+
23
+ // Match explicit credential/PII field names rather than arbitrary IDs or long strings.
24
+ const credentialField = String.raw`(?:(?:[a-z][a-z0-9]*[_-])*(?:password|passwd|pwd|(?:api|app|client|access|refresh|auth|id|session)[_-]?(?:key|secret|token)|secret|token|private[_-]?key|(?:app|alipay|rsa2?)[_-]?public[_-]?key|public[_-]?key|device[_-]?code|key[_-]?confirm[_-]?token|authorization|cookie|payment[_-]?proof|signature|auth[_-]?code|verification[_-]?code|(?:应用|支付宝)?私钥|(?:应用|支付宝|RSA2?)?公钥|密码|口令|授权码|验证码|访问令牌|访问密钥))`;
25
+ const personalField = String.raw`(?:姓名|联系人|手机号?|电话|邮箱|身份证(?:号)?|银行卡(?:号)?|家庭住址|个人住址|联系地址|收货地址|住址|full[_-]?name|phone(?:[_-]?number)?|mobile|email|id[_-]?card|bank[_-]?account|home[_-]?address)`;
26
+
27
+ function redactFields(content, field, allowSpaces = false) {
28
+ const plain = allowSpaces ? String.raw`[^\r\n,,;;。&<>"'\x60}\]]+` : String.raw`[^\s,,;;。&<>"'\x60}\]]+`;
29
+ const pattern = new RegExp(String.raw`(?<![\w.-])((?:${field})["'\x60]?\s*[::=]\s*)("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\x60[^\x60\r\n]*\x60|${plain})`, 'gi');
30
+ return content.replace(pattern, (match, prefix, value) => {
31
+ if (value === '[REDACTED') return match; // Preserve an already redacted bracketed value.
32
+ const quote = value[0];
33
+ return prefix + (['"', "'", '`'].includes(quote) && value.endsWith(quote)
34
+ ? `${quote}[REDACTED]${quote}` : '[REDACTED]');
35
+ });
36
+ }
37
+
38
+ // Recognize key material instead of masking arbitrary long IDs. Public keys are
39
+ // also excluded from collection by the Onboarding single-call usage rule.
40
+ function isEncodedKey(value) {
41
+ if (value.length < 128) return false;
42
+ const bytes = Buffer.from(value, 'base64');
43
+ try {
44
+ for (const type of ['pkcs8', 'pkcs1', 'sec1']) {
45
+ try {
46
+ createPrivateKey({ key: bytes, format: 'der', type });
47
+ return true;
48
+ } catch {}
49
+ }
50
+ for (const type of ['spki', 'pkcs1']) {
51
+ try {
52
+ createPublicKey({ key: bytes, format: 'der', type });
53
+ return true;
54
+ } catch {}
55
+ }
56
+ return false;
57
+ } finally { bytes.fill(0); }
58
+ }
59
+
60
+ // Read only the DER length header, so incomplete/invalid multiline candidates
61
+ // never cause repeated cryptographic parsing of every growing line prefix.
62
+ function encodedKeyCharacters(value) {
63
+ const header = Buffer.from(value.slice(0, 12), 'base64');
64
+ try {
65
+ if (header.length < 2 || header[0] !== 0x30) return 0;
66
+ let size = header[1];
67
+ let headerSize = 2;
68
+ if (size & 0x80) {
69
+ const count = size & 0x7f;
70
+ if (count === 0 || count > 4 || header.length < count + 2) return 0;
71
+ size = 0;
72
+ for (let index = 0; index < count; index++) size = size * 256 + header[index + 2];
73
+ headerSize += count;
74
+ }
75
+ // Count non-padding Base64 characters, including unpadded key exports.
76
+ return size > 0 ? Math.ceil((headerSize + size) * 4 / 3) : 0;
77
+ } finally { header.fill(0); }
78
+ }
79
+
80
+ function redactBareKeys(content) {
81
+ // DER keys start with an ASN.1 sequence (Base64 M[A-P]). Locate their
82
+ // declared end before parsing, preserving ordinary text after the key.
83
+ content = content.replace(/(?<![A-Za-z0-9+/])M[A-P][A-Za-z0-9+/]{14,}(?:[ \t]*\r?\n[ \t]*[A-Za-z0-9+/]+={0,2})+/g, value => {
84
+ const parts = value.split(/([ \t]*\r?\n[ \t]*)/);
85
+ const ends = [];
86
+ let characters = 0;
87
+ for (let index = 0; index < parts.length; index += 2) ends.push(characters += parts[index].length);
88
+ let result = '';
89
+ for (let start = 0; start < parts.length;) {
90
+ const size = /^M[A-P]/.test(parts[start]) ? encodedKeyCharacters(parts[start]) : 0;
91
+ const target = (ends[start / 2 - 1] || 0) + size;
92
+ let end = start;
93
+ let matched = false;
94
+ if (size && target <= ends[ends.length - 1]) {
95
+ let low = start / 2;
96
+ let high = ends.length - 1;
97
+ while (low < high) {
98
+ const middle = Math.floor((low + high) / 2);
99
+ if (ends[middle] < target) low = middle + 1;
100
+ else high = middle;
101
+ }
102
+ end = low * 2;
103
+ const encoded = parts.slice(start, end + 1).filter((_, index) => index % 2 === 0).join('');
104
+ matched = isEncodedKey(encoded);
105
+ }
106
+ if (matched) {
107
+ result += '[REDACTED]' + (parts[end + 1] || '');
108
+ start = end + 2;
109
+ } else {
110
+ result += parts[start] + (parts[start + 1] || '');
111
+ start += 2;
112
+ }
113
+ }
114
+ return result;
115
+ });
116
+ return content.replace(/(?<![A-Za-z0-9+/])[A-Za-z0-9+/]{128,}={0,2}(?![A-Za-z0-9+/=])/g,
117
+ value => isEncodedKey(value) ? '[REDACTED]' : value);
118
+ }
119
+
120
+ function redactTemporaryUrls(content) {
121
+ // Load business URL rules only during sanitization, never during startup or
122
+ // self-update. An unreadable policy conservatively excludes all URLs.
123
+ let rules;
124
+ try {
125
+ rules = Object.values(JSON.parse(readFileSync(
126
+ new URL('../json/policy-rules.json', import.meta.url), 'utf8'
127
+ )).temporaryUrlAllowlist).flat();
128
+ } catch { rules = null; }
129
+ // Remove the entire temporary URL, including encoded query names and Markdown
130
+ // link targets. Ordinary help links remain useful in the collected dialogue.
131
+ return content.replace(/https?:\\?\/\\?\/[^\s<>"'`,。;!?、))\]]+/giu, raw => {
132
+ if (!rules) return '[REDACTED]';
133
+ try {
134
+ const url = new URL(raw.replace(/\\\//g, '/'));
135
+ const credentials = [...url.searchParams.keys(), ...new URLSearchParams(url.hash.slice(1)).keys()];
136
+ if (credentials.some(name => temporaryCredential.test(name))) return '[REDACTED]';
137
+ const temporary = rules.some(rule => url.hostname === rule.host && url.pathname === rule.path
138
+ && (rule.path !== '/' || rule.requiredQueryParams.every(name => url.searchParams.has(name))));
139
+ if (temporary) return '[REDACTED]';
140
+ } catch {
141
+ if (/(?:device[_-]?code|key[_-]?confirm[_-]?token)=/i.test(raw)) return '[REDACTED]';
142
+ }
143
+ return raw;
144
+ });
145
+ }
146
+
147
+ export function sanitizeConversationText(value) {
148
+ if (typeof value !== 'string') return null;
149
+ // The caller handles personal facts in free prose semantically. This second pass
150
+ // excludes keys and temporary URLs, while retaining ordinary code, IDs and help links.
151
+ let content = value
152
+ .replace(/-----BEGIN (?:[A-Z]+ )*(?:PRIVATE|PUBLIC) KEY-----[\s\S]*?(?:-----END (?:[A-Z]+ )*(?:PRIVATE|PUBLIC) KEY-----|$)/g, '[REDACTED]')
153
+ .replace(/\b(Bearer|Basic)([ \t]+)(?!\[REDACTED\])[^\s"'`<>,;]+/gi, '$1$2[REDACTED]');
154
+ content = redactBareKeys(content);
155
+ content = redactTemporaryUrls(content);
156
+ // An unquoted Cookie header contains multiple semicolon-separated credentials.
157
+ content = content.replace(/(?<![\w.-])((?:cookie|set-cookie)[\"']?\s*[::=]\s*)(?![\"'])([^\s\"'`<>][^\r\n\"'`<>]*)/gi, '$1[REDACTED]');
158
+ content = redactFields(content, credentialField);
159
+ content = redactFields(content, personalField, true);
160
+ content = content
161
+ .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, '[REDACTED]')
162
+ .replace(/(?<![\w])(?:\+?86[- ]?)?1[3-9]\d{9}(?![\w])/g, '[REDACTED]')
163
+ .replace(/(?<![\w])\d{6}(?:18|19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx](?![\w])/g, '[REDACTED]');
164
+ // Retain the existing local schema flag; new messages are never truncated.
165
+ return { content, truncated: false };
166
+ }
167
+
168
+ export function normalizeConversationMessageInput(message) {
169
+ if (!message || typeof message !== 'object' || Array.isArray(message)) return null;
170
+ if (typeof message.role === 'string' && typeof message.content === 'string') return message;
171
+ if (typeof message.text === 'string' && !Object.prototype.hasOwnProperty.call(message, 'role')
172
+ && !Object.prototype.hasOwnProperty.call(message, 'content')) {
173
+ return {
174
+ role: 'user',
175
+ content: message.text,
176
+ ...(typeof message.has_attachments === 'boolean' ? { has_attachments: message.has_attachments } : {})
177
+ };
178
+ }
179
+ return null;
180
+ }
181
+
182
+ export function validConversationId(value) {
183
+ return typeof value === 'string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(value);
184
+ }
185
+
186
+ export function normalizeConversationEvent(event) {
187
+ if (!event || Array.isArray(event) || Object.keys(event).some((key) => !fields.has(key))) return null;
188
+ if (event.schema_ver !== 5 || event.kind !== 'conversation_message' || event.skill !== 'alipay-aipay'
189
+ || typeof event.code !== 'string' || !conversationCode({ ALIPAY_AIPAY_CONVERSATION_CODE: event.code })
190
+ || !validConversationId(event.conversation_id)
191
+ || typeof event.skill_version !== 'string' || !/^(?:unknown|v?\d+\.\d+\.\d+(?:[-+][\w.+-]+)?)$/.test(event.skill_version)
192
+ || (event.product_type !== null && !['AIPAY', 'WEBPAY', 'APPPAY'].includes(event.product_type))
193
+ || !Number.isSafeInteger(event.timestamp) || event.timestamp <= 0
194
+ || !roles.has(event.role) || !Number.isSafeInteger(event.sequence) || event.sequence < 1
195
+ || typeof event.has_attachments !== 'boolean' || typeof event.truncated !== 'boolean'
196
+ || event.redaction_version !== 1) return null;
197
+ const sanitized = sanitizeConversationText(event.content);
198
+ if (!sanitized) return null;
199
+ return { ...event, content: sanitized.content, truncated: event.truncated || sanitized.truncated };
200
+ }
@@ -18,8 +18,10 @@ const policy = JSON.parse(fs.readFileSync(policyFile, "utf8"));
18
18
  if (/[\u0000-\u0020\u007f`()\[\]<>|]/.test(raw)) process.exit(1);
19
19
  let url;
20
20
  try { url = new URL(raw); } catch { process.exit(1); }
21
- const allowed = policy.temporaryUrlAllowlist[kind === "authorization" ? "authorization" : "publicKeyConfirmation"];
22
- if (!allowed || url.protocol !== "https:" || url.hostname !== allowed.host || url.pathname !== allowed.path || url.username || url.password || url.port || url.hash) process.exit(1);
21
+ const entries = policy.temporaryUrlAllowlist[kind === "authorization" ? "authorization" : "publicKeyConfirmation"];
22
+ const allowed = (Array.isArray(entries) ? entries : [entries]).find((entry) =>
23
+ entry && url.hostname === entry.host && url.pathname === entry.path);
24
+ if (!allowed || url.protocol !== "https:" || url.username || url.password || url.port || url.hash) process.exit(1);
23
25
  if (kind === "authorization") {
24
26
  const required = allowed.requiredQueryParams;
25
27
  const optional = allowed.optionalQueryParams;
@@ -343,8 +343,10 @@ function validateUrl(value, policy, urlKind, expected = {}) {
343
343
  } catch {
344
344
  throw new Error('officialTemporaryUrl 不是合法 URL');
345
345
  }
346
- const allowed = policy.temporaryUrlAllowlist[urlKind];
347
- if (!allowed || parsed.protocol !== 'https:' || parsed.hostname !== allowed.host || parsed.pathname !== allowed.path || parsed.username || parsed.password || parsed.port || parsed.hash) {
346
+ const entries = policy.temporaryUrlAllowlist[urlKind];
347
+ const allowed = (Array.isArray(entries) ? entries : [entries]).find((entry) =>
348
+ entry && parsed.hostname === entry.host && parsed.pathname === entry.path);
349
+ if (!allowed || parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port || parsed.hash) {
348
350
  throw new Error(`officialTemporaryUrl 不符合 ${urlKind} allowlist`);
349
351
  }
350
352
  if (urlKind === 'authorization') {
@@ -899,6 +901,8 @@ function validateVariantInputShape(messageId, variantName, variables) {
899
901
  }
900
902
 
901
903
  const managedEntrypoints = {
904
+ 'conversation.notice': { command: 'node "<SKILL_DIR>/references/normal/scripts/runtime.mjs" conversation notice', reason: '采集说明由 conversation 托管。', allowedCallers: [] },
905
+ 'conversation.consent.clarify': { command: 'node "<SKILL_DIR>/references/normal/scripts/runtime.mjs" conversation consent --reply <用户原文> --input-id <消息幂等键>', reason: '采集选择由专用解析器处理。', allowedCallers: [] },
902
906
  'product.unsupported': {
903
907
  command: 'node "<SKILL_DIR>/references/normal/scripts/runtime.mjs" integration-message product-unsupported --product-name <产品名>',
904
908
  reason: 'runner 托管,禁手写。'
@@ -28,6 +28,7 @@ const shellEntrypoints = Object.freeze({
28
28
  });
29
29
 
30
30
  const nodeEntrypoints = Object.freeze({
31
+ conversation: ['conversation_consent.mjs', 'runConversationConsentCli'],
31
32
  'channel-offer': ['channel_offer.mjs', 'runChannelOfferCli'],
32
33
  telemetry: ['telemetry.mjs', 'runTelemetryCli'],
33
34
  flow: ['flow_loader.mjs', 'runFlowCli'],
@@ -307,17 +307,14 @@ function removeTransactionPath(target) {
307
307
 
308
308
  function reconcileInstallTransaction(skillDir, transactionId, channel) {
309
309
  const artifacts = transactionArtifacts(skillDir, transactionId, channel);
310
- let current = readLocalVersion(skillDir);
311
- if (!current && isRecoverableSkillRoot(artifacts.backup)) {
310
+ if (isRecoverableSkillRoot(artifacts.backup)) {
312
311
  try {
313
312
  removeTransactionPath(skillDir);
314
313
  fs.renameSync(artifacts.backup, skillDir);
315
314
  } catch {}
316
- current = readLocalVersion(skillDir);
317
315
  }
318
316
  removeTransactionPath(artifacts.staging);
319
- if (isRecoverableSkillRoot(skillDir)) removeTransactionPath(artifacts.backup);
320
- return current;
317
+ return { version: readLocalVersion(skillDir), backupPresent: fs.existsSync(artifacts.backup) };
321
318
  }
322
319
 
323
320
  async function installVersion(skillDir, packageVersion, channel) {
@@ -351,10 +348,11 @@ async function installVersion(skillDir, packageVersion, channel) {
351
348
  } catch { /* An invalid receipt never permits a session switch. */ }
352
349
  }
353
350
  }
351
+ const reconciled = reconcileInstallTransaction(skillDir, transactionId, channel);
354
352
  return {
355
353
  ok: result.status === 0 && !result.error,
356
354
  reason: result.status === 0 && !result.error ? 'ok' : packageManagerFailureKind(result),
357
- version: reconcileInstallTransaction(skillDir, transactionId, channel),
355
+ ...reconciled,
358
356
  session,
359
357
  };
360
358
  }
@@ -395,8 +393,9 @@ export async function runSelfUpdateCli(argv = process.argv) {
395
393
  if (installed.session && installed.session.version===latest.raw) {
396
394
  return {kind:'SESSION_READY',version:latest,skillDir:installed.session.skillDir};
397
395
  }
396
+ if (installed.backupPresent) return { kind: 'INSTALL_FAILED' };
398
397
  const updated = installed.version || readLocalVersion(skillDir);
399
- if (updated && readChannel(skillDir) === channel && compareVersions(updated, latest) >= 0) return { kind: 'UPDATED', version: updated };
398
+ if (installed.ok && updated && readChannel(skillDir) === channel && compareVersions(updated, latest) >= 0) return { kind: 'UPDATED', version: updated };
400
399
  if (installed.reason === 'network') return { kind: 'RETRY_WITH_NETWORK' };
401
400
  if (installed.reason === 'local') return { kind: 'CHECK_FAILED' };
402
401
  return { kind: installed.ok ? 'VERIFY_FAILED' : 'INSTALL_FAILED' };