@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.
- package/README.md +2 -2
- package/package.json +3 -3
- package/skill/SKILL.md +34 -3
- package/skill/VERSION +1 -1
- package/skill/references/normal/conversation-telemetry.md +103 -0
- package/skill/references/normal/json/customer-messages.json +1 -1
- package/skill/references/normal/json/policy-rules.json +1 -1
- package/skill/references/normal/scripts/conversation_consent.mjs +76 -0
- package/skill/references/normal/scripts/conversation_telemetry.mjs +200 -0
- package/skill/references/normal/scripts/open_official_url.sh +4 -2
- package/skill/references/normal/scripts/render_customer_message.mjs +6 -2
- package/skill/references/normal/scripts/runtime.mjs +1 -0
- package/skill/references/normal/scripts/self_update.mjs +6 -7
- package/skill/references/normal/scripts/telemetry.mjs +618 -74
- package/skill/references/normal/scripts/windows/bootstrap-node.ps1 +0 -2
- package/skill/references/runtime-entry.md +7 -1
- package/skill-pre/VERSION +1 -1
- package/skill-pre/references/normal/conversation-telemetry.md +9 -9
- package/skill-pre/references/normal/scripts/conversation_telemetry.mjs +113 -18
- package/skill-pre/references/normal/scripts/runtime.mjs +4 -4
- package/skill-pre/references/normal/scripts/self_update.mjs +6 -7
- package/skill-pre/references/normal/scripts/telemetry.mjs +243 -32
- package/skill-pre/references/normal/scripts/windows/git-discovery.mjs +6 -2
- package/skill-pre/references/normal/scripts/windows/git-install.mjs +2 -3
- package/skill-pre/references/normal/scripts/windows/preflight.mjs +13 -2
- package/skill-pre/references/normal/scripts/windows/prepare.mjs +2 -2
- package/skill-pre/references/normal/scripts/windows/routing.mjs +4 -1
- 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
|
|
22
|
-
|
|
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
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' };
|