evolcore 0.0.24 → 0.0.26
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/CHANGELOG.md +59 -0
- package/bin/ec.js +12 -1
- package/bin/install-codex-managed-hooks.mjs +28 -1
- package/dist/agents/baseagent.js +96 -46
- package/dist/agents/claude-runner.js +54 -13
- package/dist/agents/codex/app-server-client.js +13 -5
- package/dist/agents/codex/catalog.js +1 -1
- package/dist/agents/codex/diagnostics.js +34 -1
- package/dist/agents/codex-runner.js +128 -42
- package/dist/agents/ecagent-runner.js +14 -18
- package/dist/agents/gemini-runner.js +24 -9
- package/dist/agents/runner-types.js +25 -17
- package/dist/aun/aid/agentmd.js +135 -10
- package/dist/aun/aid/client.js +1 -1
- package/dist/aun/aid/identity.js +1 -1
- package/dist/aun/aid/index.js +2 -1
- package/dist/aun/aid/managed-operation.js +53 -1
- package/dist/aun/aid/store.js +17 -13
- package/dist/aun/menu-client.js +74 -0
- package/dist/aun/msg/group.js +1 -1
- package/dist/aun/msg/managed-operation.js +1 -1
- package/dist/aun/msg/p2p.js +6 -1
- package/dist/aun/msg/upload.js +30 -22
- package/dist/aun/storage/client.js +4 -1
- package/dist/aun/storage/index.js +1 -1
- package/dist/channels/aun.js +329 -94
- package/dist/cli/agent-command.js +125 -5
- package/dist/cli/agent-create.js +151 -11
- package/dist/cli/agent.js +119 -6
- package/dist/cli/aun-commands.js +74 -7
- package/dist/cli/config.js +27 -8
- package/dist/cli/contact.js +63 -6
- package/dist/cli/daemon-commands.js +90 -25
- package/dist/cli/diag.js +31 -0
- package/dist/cli/handoff-command.js +35 -3
- package/dist/cli/index.js +7 -1
- package/dist/cli/managed-command-guard.js +3 -0
- package/dist/cli/menu-client.js +38 -23
- package/dist/cli/restart-monitor.js +57 -10
- package/dist/config/builtin-roles.js +2 -0
- package/dist/config/config-manager.js +15 -0
- package/dist/config/config-operation-service.js +165 -3
- package/dist/config/contact-operation-service.js +96 -7
- package/dist/config/resolved-config-op.js +66 -8
- package/dist/core/agent-application-service.js +2 -1
- package/dist/core/auth/authorization-audit.js +20 -12
- package/dist/core/auth/operation-authorizer.js +87 -8
- package/dist/core/auth/operation-catalog.js +51 -4
- package/dist/core/auth/remote-access-store.js +40 -1
- package/dist/core/bootstrap-service.js +1 -1
- package/dist/core/command/agent-control.js +54 -25
- package/dist/core/command/cli-intent-parser.js +18 -0
- package/dist/core/command/command-handler.js +152 -20
- package/dist/core/command/diag-service.js +118 -0
- package/dist/core/command/menu-catalog.js +13 -3
- package/dist/core/command/menu-handler.js +88 -31
- package/dist/core/command/menu-protocol.js +6 -0
- package/dist/core/command/menu-token-store.js +41 -8
- package/dist/core/command/slash-gate.js +34 -4
- package/dist/core/command/slash-handler.js +77 -12
- package/dist/core/event-catalog.js +5 -0
- package/dist/core/evolagent.js +3 -3
- package/dist/core/handoff/runtime.js +205 -27
- package/dist/core/handoff/store.js +193 -4
- package/dist/core/message/im-renderer.js +72 -21
- package/dist/core/message/message-bridge.js +69 -9
- package/dist/core/message/message-queue.js +26 -0
- package/dist/core/message/message-utils.js +1 -0
- package/dist/core/message/response-engine.js +48 -23
- package/dist/core/model/catalog-service.js +79 -49
- package/dist/core/model/context-window-suffix.js +33 -0
- package/dist/core/model/model-catalog.js +1 -1
- package/dist/core/model/model-permission.js +12 -3
- package/dist/core/permission/ec-command-parser.js +51 -12
- package/dist/core/permission/sandbox-runtime.js +36 -14
- package/dist/core/permission/tool-policy.js +47 -7
- package/dist/index.js +195 -31
- package/dist/ipc.js +54 -2
- package/dist/response-system/engines/v1/proactive-flow.js +59 -2
- package/dist/stats/billing.js +41 -15
- package/dist/stats/price-resolver.js +28 -10
- package/dist/utils/avatar-upload.js +282 -23
- package/dist/utils/cross-platform.js +30 -1
- package/dist/utils/error-utils.js +21 -0
- package/dist/utils/evolcore-version.js +129 -0
- package/dist/utils/logger.js +3 -1
- package/dist/utils/model-prices.jsonl +3 -3
- package/dist/utils/npm-ops.js +176 -35
- package/kits/docs/context-assembly.md +1 -0
- package/kits/docs/evolcore/INDEX.md +3 -1
- package/kits/docs/evolcore/agent-create.md +30 -11
- package/kits/docs/evolcore/agent.md +18 -8
- package/kits/docs/evolcore/aid.md +6 -4
- package/kits/docs/evolcore/codex-windows-sandbox.md +146 -0
- package/kits/docs/evolcore/config.md +7 -0
- package/kits/docs/evolcore/contact.md +12 -5
- package/kits/docs/evolcore/menu-client.md +11 -3
- package/kits/docs/evolcore/msg.md +1 -0
- package/kits/docs/prompt-loading-architecture.md +1 -0
- package/kits/docs/venues/group.md +12 -0
- package/kits/eck_manifest.json +12 -0
- package/kits/rules/03-identity.md +3 -1
- package/kits/templates/message-fragments/handoff-request-to-target.md +3 -3
- package/kits/templates/roles/admin.json +14 -2
- package/kits/templates/roles/member.json +3 -2
- package/kits/templates/roles/visitor.json +4 -3
- package/kits/templates/system-fragments/bootstrap.md +16 -5
- package/kits/templates/system-fragments/channel.md +1 -1
- package/kits/templates/system-fragments/session.md +1 -1
- package/package.json +6 -4
- package/skills/eclink/SKILL.md +0 -84
- package/skills/eclink/agents/openai.yaml +0 -4
package/dist/stats/billing.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import fs from 'fs';
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import { getPackageRoot } from '../paths.js';
|
|
8
|
+
import { parseModelId, stripContextWindowSuffix } from '../core/model/context-window-suffix.js';
|
|
8
9
|
// 模型参数缓存(进程内,5 分钟 TTL)
|
|
9
10
|
let _parameterCache = null;
|
|
10
11
|
let _parameterCacheTs = 0;
|
|
@@ -79,21 +80,36 @@ function loadPrices(evolcoreHome) {
|
|
|
79
80
|
_priceCacheTs = now;
|
|
80
81
|
return _priceCache;
|
|
81
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* 按优先级列出一个模型 ID 的查表候选键。
|
|
85
|
+
*
|
|
86
|
+
* 上下文窗口变体(`claude-opus-5[1m]`)与基础模型同价,价表只需一条基础条目;
|
|
87
|
+
* 但仍先试精确匹配,好让用户能给某个变体单独定价。
|
|
88
|
+
*/
|
|
89
|
+
export function modelLookupKeys(evolcoreHome, model) {
|
|
90
|
+
const keys = [];
|
|
91
|
+
const push = (key) => { if (key && !keys.includes(key))
|
|
92
|
+
keys.push(key); };
|
|
93
|
+
push(model);
|
|
94
|
+
push(resolveCanonicalModel(evolcoreHome, model));
|
|
95
|
+
const base = stripContextWindowSuffix(model);
|
|
96
|
+
if (base !== model) {
|
|
97
|
+
push(base);
|
|
98
|
+
push(resolveCanonicalModel(evolcoreHome, base));
|
|
99
|
+
}
|
|
100
|
+
return keys;
|
|
101
|
+
}
|
|
82
102
|
/** 取 model 在 ts 时刻生效的价格行(effective_from <= ts 中最新的一条)。
|
|
83
|
-
*
|
|
103
|
+
* 精确匹配失败时,依次回落到 alias 规范 ID 和去掉窗口后缀的基础 ID。 */
|
|
84
104
|
export function resolvePriceRow(evolcoreHome, model, ts) {
|
|
85
105
|
const prices = loadPrices(evolcoreHome);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (canonical !== model) {
|
|
91
|
-
candidates = prices.filter(p => p.model === canonical && p.effective_from <= ts);
|
|
106
|
+
for (const key of modelLookupKeys(evolcoreHome, model)) {
|
|
107
|
+
const candidates = prices.filter(p => p.model === key && p.effective_from <= ts);
|
|
108
|
+
if (candidates.length) {
|
|
109
|
+
return candidates.reduce((a, b) => a.effective_from >= b.effective_from ? a : b);
|
|
92
110
|
}
|
|
93
111
|
}
|
|
94
|
-
|
|
95
|
-
return null;
|
|
96
|
-
return candidates.reduce((a, b) => a.effective_from >= b.effective_from ? a : b);
|
|
112
|
+
return null;
|
|
97
113
|
}
|
|
98
114
|
export const BILLING_FNS = {
|
|
99
115
|
// 通用 per-token(Claude / OpenAI 兼容 / Kimi / MiniMax)
|
|
@@ -164,14 +180,24 @@ function loadSpecs(evolcoreHome) {
|
|
|
164
180
|
_specCacheTs = now;
|
|
165
181
|
return _specCache;
|
|
166
182
|
}
|
|
167
|
-
/** 取模型在 ts 时刻的能力参数。找不到时返回默认值。
|
|
183
|
+
/** 取模型在 ts 时刻的能力参数。找不到时返回默认值。
|
|
184
|
+
* 带窗口后缀的 ID 复用基础模型的参数行,但窗口由后缀决定(网关的权威声明)。 */
|
|
168
185
|
export function resolveModelSpec(evolcoreHome, model, ts) {
|
|
169
186
|
const specs = loadSpecs(evolcoreHome);
|
|
170
187
|
const t = ts ?? Date.now();
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
188
|
+
const suffixWindow = parseModelId(model).contextWindow;
|
|
189
|
+
for (const key of modelLookupKeys(evolcoreHome, model)) {
|
|
190
|
+
const candidates = specs.filter(s => s.model === key && s.effective_from <= t);
|
|
191
|
+
if (!candidates.length)
|
|
192
|
+
continue;
|
|
193
|
+
const row = candidates.reduce((a, b) => a.effective_from >= b.effective_from ? a : b);
|
|
194
|
+
if (suffixWindow === undefined || suffixWindow === row.context_window)
|
|
195
|
+
return row;
|
|
196
|
+
// 后缀声明的窗口覆盖基础行,输入上限按原行的预留比例等比缩放。
|
|
197
|
+
const ratio = row.context_window > 0 ? row.max_input_tokens / row.context_window : 0.9;
|
|
198
|
+
return { ...row, model, context_window: suffixWindow, max_input_tokens: Math.floor(suffixWindow * ratio) };
|
|
174
199
|
}
|
|
175
200
|
// 默认值
|
|
176
|
-
|
|
201
|
+
const contextWindow = suffixWindow ?? 200000;
|
|
202
|
+
return { model, effective_from: 0, context_window: contextWindow, max_input_tokens: Math.floor(contextWindow * 0.9), max_output_tokens: 8192 };
|
|
177
203
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* price-resolver.ts — 统一价格解析逻辑,支持官方价格和网关价格。
|
|
3
3
|
* 供 writer.ts 写入时调用,供 rebuild 命令回填历史数据。
|
|
4
4
|
*/
|
|
5
|
-
import { resolvePriceRow, BILLING_FNS } from './billing.js';
|
|
5
|
+
import { resolvePriceRow, modelLookupKeys, BILLING_FNS } from './billing.js';
|
|
6
6
|
import fs from 'fs';
|
|
7
7
|
import path from 'path';
|
|
8
8
|
/** Keep wire/log metadata readable while preserving full precision in storage. */
|
|
@@ -44,10 +44,28 @@ function loadGatewayPrices(evolcoreHome) {
|
|
|
44
44
|
*/
|
|
45
45
|
function resolveGatewayPriceRow(evolcoreHome, model, ts) {
|
|
46
46
|
const prices = loadGatewayPrices(evolcoreHome);
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
// 精确匹配优先(允许给某个窗口变体单独定价),再回落到基础 ID。
|
|
48
|
+
for (const key of modelLookupKeys(evolcoreHome, model)) {
|
|
49
|
+
const candidates = prices.filter(p => p.model === key && p.effective_from <= ts);
|
|
50
|
+
if (candidates.length) {
|
|
51
|
+
return candidates.reduce((a, b) => a.effective_from >= b.effective_from ? a : b);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* 从网关价格 Map 里取价:Map 的键是网关原样下发的 ID(可能带 `[1m]` 后缀),
|
|
58
|
+
* 而 usage 事件记录的可能是基础 ID,反之亦然——两侧都要按候选键链匹配。
|
|
59
|
+
*/
|
|
60
|
+
function pickGatewayQuad(evolcoreHome, map, model) {
|
|
61
|
+
if (!map)
|
|
49
62
|
return null;
|
|
50
|
-
|
|
63
|
+
for (const key of modelLookupKeys(evolcoreHome, model)) {
|
|
64
|
+
const quad = map.get(key);
|
|
65
|
+
if (quad)
|
|
66
|
+
return quad;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
51
69
|
}
|
|
52
70
|
/**
|
|
53
71
|
* 从 PriceQuad 构造一个符合 billing.ts 规范的 PriceRecord。
|
|
@@ -80,9 +98,9 @@ export function resolvePrices(evolcoreHome, event, gatewayPricing) {
|
|
|
80
98
|
// ── 1. 官方价格(Official Price)──────────────────────────────────────────
|
|
81
99
|
let officialCost = null;
|
|
82
100
|
// 优先级 1: 网关接口返回的 pricing 字段
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const priceRow = priceQuadToRecord(event.model, event.ts,
|
|
101
|
+
const officialQuad = pickGatewayQuad(evolcoreHome, gatewayPricing?.official, event.model);
|
|
102
|
+
if (officialQuad) {
|
|
103
|
+
const priceRow = priceQuadToRecord(event.model, event.ts, officialQuad, event.billing_fn);
|
|
86
104
|
const fn = BILLING_FNS[event.billing_fn];
|
|
87
105
|
if (fn) {
|
|
88
106
|
officialCost = fn(event, priceRow);
|
|
@@ -109,9 +127,9 @@ export function resolvePrices(evolcoreHome, event, gatewayPricing) {
|
|
|
109
127
|
}
|
|
110
128
|
}
|
|
111
129
|
// 优先级 2: 网关接口返回的 effective_pricing 字段
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const priceRow = priceQuadToRecord(event.model, event.ts,
|
|
130
|
+
const gatewayQuad = gatewayCost ? null : pickGatewayQuad(evolcoreHome, gatewayPricing?.gateway, event.model);
|
|
131
|
+
if (gatewayQuad) {
|
|
132
|
+
const priceRow = priceQuadToRecord(event.model, event.ts, gatewayQuad, event.billing_fn);
|
|
115
133
|
const fn = BILLING_FNS[event.billing_fn];
|
|
116
134
|
if (fn) {
|
|
117
135
|
gatewayCost = fn(event, priceRow);
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
-
import os from 'os';
|
|
3
|
-
import path from 'path';
|
|
4
2
|
import crypto from 'crypto';
|
|
3
|
+
import dns from 'dns/promises';
|
|
4
|
+
import net from 'net';
|
|
5
5
|
import { agentmdGet, agentmdPut, updateAgentMdFrontmatterAvatar } from '../aun/aid/agentmd.js';
|
|
6
6
|
import { storageRm, storageUpload } from '../aun/storage/index.js';
|
|
7
7
|
import { logger } from './logger.js';
|
|
8
8
|
import { validateImage } from './media-cache.js';
|
|
9
9
|
const MAX_AVATAR_BYTES = 500 * 1024;
|
|
10
|
+
const MIN_AVATAR_DIMENSION = 128;
|
|
11
|
+
const MAX_AVATAR_DIMENSION = 2048;
|
|
12
|
+
const MIN_AVATAR_RATIO = 0.8;
|
|
13
|
+
const MAX_AVATAR_RATIO = 1.25;
|
|
14
|
+
const MAX_AVATAR_REDIRECTS = 3;
|
|
15
|
+
const AVATAR_DOWNLOAD_TIMEOUT_MS = 10_000;
|
|
16
|
+
const AVATAR_DNS_TIMEOUT_MS = 2_000;
|
|
10
17
|
const ALLOWED_AVATAR_MIMES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
|
11
18
|
const MIME_EXT = {
|
|
12
19
|
'image/png': 'png',
|
|
@@ -64,16 +71,230 @@ export async function uploadAvatar(aid, dataUrl, opts) {
|
|
|
64
71
|
const parsed = parseAvatarDataUrl(dataUrl);
|
|
65
72
|
if ('ok' in parsed)
|
|
66
73
|
return parsed;
|
|
67
|
-
const validated = await
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
});
|
|
71
|
-
if (validated.mime === null) {
|
|
72
|
-
return fail(`头像格式验证失败:${validated.reason}`, 'INVALID_ARGS');
|
|
73
|
-
}
|
|
74
|
+
const validated = await validateAvatarBuffer(parsed.buffer);
|
|
75
|
+
if (!('dimensions' in validated))
|
|
76
|
+
return validated;
|
|
74
77
|
if (validated.mime !== parsed.mime) {
|
|
75
78
|
return fail(`头像格式验证失败:声明 ${parsed.mime},实际 ${validated.mime}`, 'INVALID_ARGS');
|
|
76
79
|
}
|
|
80
|
+
return uploadValidatedAvatar(aid, parsed.buffer, validated.mime, validated.dimensions, opts);
|
|
81
|
+
}
|
|
82
|
+
function parseJpegDimensions(buffer) {
|
|
83
|
+
let offset = 2;
|
|
84
|
+
while (offset + 3 < buffer.length) {
|
|
85
|
+
if (buffer[offset] !== 0xff) {
|
|
86
|
+
offset++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
while (offset < buffer.length && buffer[offset] === 0xff)
|
|
90
|
+
offset++;
|
|
91
|
+
if (offset >= buffer.length)
|
|
92
|
+
return undefined;
|
|
93
|
+
const marker = buffer[offset++];
|
|
94
|
+
if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7))
|
|
95
|
+
continue;
|
|
96
|
+
if (offset + 1 >= buffer.length)
|
|
97
|
+
return undefined;
|
|
98
|
+
const segmentLength = buffer.readUInt16BE(offset);
|
|
99
|
+
if (segmentLength < 2 || offset + segmentLength > buffer.length)
|
|
100
|
+
return undefined;
|
|
101
|
+
const isSof = (marker >= 0xc0 && marker <= 0xc3)
|
|
102
|
+
|| (marker >= 0xc5 && marker <= 0xc7)
|
|
103
|
+
|| (marker >= 0xc9 && marker <= 0xcb)
|
|
104
|
+
|| (marker >= 0xcd && marker <= 0xcf);
|
|
105
|
+
if (isSof && segmentLength >= 7) {
|
|
106
|
+
const height = buffer.readUInt16BE(offset + 3);
|
|
107
|
+
const width = buffer.readUInt16BE(offset + 5);
|
|
108
|
+
return { width, height };
|
|
109
|
+
}
|
|
110
|
+
offset += segmentLength;
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
function parseWebpDimensions(buffer) {
|
|
115
|
+
if (buffer.length < 16 || buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WEBP')
|
|
116
|
+
return undefined;
|
|
117
|
+
const chunk = buffer.toString('ascii', 12, 16);
|
|
118
|
+
if (chunk === 'VP8X' && buffer.length >= 30) {
|
|
119
|
+
const width = 1 + buffer[24] + (buffer[25] << 8) + (buffer[26] << 16);
|
|
120
|
+
const height = 1 + buffer[27] + (buffer[28] << 8) + (buffer[29] << 16);
|
|
121
|
+
return { width, height };
|
|
122
|
+
}
|
|
123
|
+
if (chunk === 'VP8L' && buffer.length >= 25 && buffer[20] === 0x2f) {
|
|
124
|
+
const bits = buffer[21] | (buffer[22] << 8) | (buffer[23] << 16) | (buffer[24] << 24);
|
|
125
|
+
const width = 1 + (bits & 0x3fff);
|
|
126
|
+
const height = 1 + ((bits >>> 14) & 0x3fff);
|
|
127
|
+
return { width, height };
|
|
128
|
+
}
|
|
129
|
+
if (chunk === 'VP8 ' && buffer.length >= 30) {
|
|
130
|
+
const frame = buffer.indexOf(Buffer.from([0x9d, 0x01, 0x2a]), 20);
|
|
131
|
+
if (frame >= 0 && frame + 7 < buffer.length) {
|
|
132
|
+
return { width: buffer.readUInt16LE(frame + 3) & 0x3fff, height: buffer.readUInt16LE(frame + 5) & 0x3fff };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
function parseAvatarDimensions(buffer, mime) {
|
|
138
|
+
try {
|
|
139
|
+
if (mime === 'image/png' && buffer.length >= 24
|
|
140
|
+
&& buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
|
|
141
|
+
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
|
142
|
+
}
|
|
143
|
+
if (mime === 'image/jpeg' && buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8)
|
|
144
|
+
return parseJpegDimensions(buffer);
|
|
145
|
+
if (mime === 'image/webp')
|
|
146
|
+
return parseWebpDimensions(buffer);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
function validateAvatarDimensions(buffer, mime) {
|
|
154
|
+
const dimensions = parseAvatarDimensions(buffer, mime);
|
|
155
|
+
if (!dimensions || !Number.isSafeInteger(dimensions.width) || !Number.isSafeInteger(dimensions.height)
|
|
156
|
+
|| dimensions.width < MIN_AVATAR_DIMENSION || dimensions.height < MIN_AVATAR_DIMENSION
|
|
157
|
+
|| dimensions.width > MAX_AVATAR_DIMENSION || dimensions.height > MAX_AVATAR_DIMENSION) {
|
|
158
|
+
return fail(`头像分辨率必须在 ${MIN_AVATAR_DIMENSION}x${MIN_AVATAR_DIMENSION} 至 ${MAX_AVATAR_DIMENSION}x${MAX_AVATAR_DIMENSION} 之间`, 'INVALID_ARGS');
|
|
159
|
+
}
|
|
160
|
+
const aspectRatio = dimensions.width / dimensions.height;
|
|
161
|
+
if (aspectRatio < MIN_AVATAR_RATIO || aspectRatio > MAX_AVATAR_RATIO) {
|
|
162
|
+
return fail(`头像宽高比必须在 ${MIN_AVATAR_RATIO} 至 ${MAX_AVATAR_RATIO} 之间(当前: ${aspectRatio.toFixed(3)})`, 'INVALID_ARGS');
|
|
163
|
+
}
|
|
164
|
+
return dimensions;
|
|
165
|
+
}
|
|
166
|
+
function isPrivateAddress(address) {
|
|
167
|
+
if (net.isIPv4(address)) {
|
|
168
|
+
const [a, b] = address.split('.').map(Number);
|
|
169
|
+
return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254)
|
|
170
|
+
|| (a === 100 && b >= 64 && b <= 127) || (a === 172 && b >= 16 && b <= 31)
|
|
171
|
+
|| (a === 192 && (b === 0 || b === 168)) || (a === 198 && (b === 18 || b === 19))
|
|
172
|
+
|| a >= 224;
|
|
173
|
+
}
|
|
174
|
+
if (net.isIPv6(address)) {
|
|
175
|
+
const normalized = address.toLowerCase();
|
|
176
|
+
const mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1];
|
|
177
|
+
if (mapped)
|
|
178
|
+
return isPrivateAddress(mapped);
|
|
179
|
+
return normalized === '::1' || normalized === '::' || normalized.startsWith('fc')
|
|
180
|
+
|| normalized.startsWith('fd') || normalized.startsWith('fe8') || normalized.startsWith('fe9')
|
|
181
|
+
|| normalized.startsWith('fea') || normalized.startsWith('feb') || normalized.startsWith('ff');
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
async function validateAvatarUrl(url) {
|
|
186
|
+
let parsed;
|
|
187
|
+
try {
|
|
188
|
+
parsed = new URL(url);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return { ok: false, reason: '头像 URL 无效' };
|
|
192
|
+
}
|
|
193
|
+
if (parsed.protocol !== 'https:')
|
|
194
|
+
return { ok: false, reason: '头像 URL 仅支持 HTTPS' };
|
|
195
|
+
if (!parsed.hostname)
|
|
196
|
+
return { ok: false, reason: '头像 URL 缺少主机名' };
|
|
197
|
+
if (parsed.username || parsed.password)
|
|
198
|
+
return { ok: false, reason: '头像 URL 不允许携带认证信息' };
|
|
199
|
+
if (parsed.port && parsed.port !== '443')
|
|
200
|
+
return { ok: false, reason: '头像 URL 仅支持 HTTPS 默认端口' };
|
|
201
|
+
const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
|
|
202
|
+
if (isPrivateAddress(hostname))
|
|
203
|
+
return { ok: false, reason: '头像 URL 指向受限地址' };
|
|
204
|
+
let dnsTimer;
|
|
205
|
+
try {
|
|
206
|
+
const addresses = await Promise.race([
|
|
207
|
+
dns.lookup(hostname, { all: true, verbatim: true }),
|
|
208
|
+
new Promise((_, reject) => { dnsTimer = setTimeout(() => reject(new Error('DNS lookup timeout')), AVATAR_DNS_TIMEOUT_MS); }),
|
|
209
|
+
]);
|
|
210
|
+
if (dnsTimer)
|
|
211
|
+
clearTimeout(dnsTimer);
|
|
212
|
+
if (!addresses.length || addresses.some(({ address }) => isPrivateAddress(address))) {
|
|
213
|
+
return { ok: false, reason: '头像 URL 指向受限地址' };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
if (dnsTimer)
|
|
218
|
+
clearTimeout(dnsTimer);
|
|
219
|
+
return { ok: false, reason: '头像 URL 主机名无法解析' };
|
|
220
|
+
}
|
|
221
|
+
return { ok: true, url: parsed };
|
|
222
|
+
}
|
|
223
|
+
async function downloadAvatarBuffer(sourceUrl) {
|
|
224
|
+
let current = sourceUrl;
|
|
225
|
+
for (let redirect = 0; redirect <= MAX_AVATAR_REDIRECTS; redirect++) {
|
|
226
|
+
const checked = await validateAvatarUrl(current);
|
|
227
|
+
if (!checked.ok)
|
|
228
|
+
return fail(checked.reason, 'INVALID_ARGS');
|
|
229
|
+
const controller = new AbortController();
|
|
230
|
+
const timeout = setTimeout(() => controller.abort(), AVATAR_DOWNLOAD_TIMEOUT_MS);
|
|
231
|
+
let response;
|
|
232
|
+
try {
|
|
233
|
+
response = await fetch(checked.url, {
|
|
234
|
+
redirect: 'manual',
|
|
235
|
+
headers: { accept: 'image/png,image/jpeg,image/webp', 'accept-encoding': 'identity' },
|
|
236
|
+
signal: controller.signal,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
clearTimeout(timeout);
|
|
241
|
+
return fail(`头像 URL 下载失败: ${messageOf(e)}`, 'UPLOAD_FAILED');
|
|
242
|
+
}
|
|
243
|
+
if (response.status >= 300 && response.status < 400) {
|
|
244
|
+
const location = response.headers.get('location');
|
|
245
|
+
clearTimeout(timeout);
|
|
246
|
+
if (!location || redirect === MAX_AVATAR_REDIRECTS)
|
|
247
|
+
return fail('头像 URL 重定向次数过多或缺少目标', 'INVALID_ARGS');
|
|
248
|
+
try {
|
|
249
|
+
current = new URL(location, checked.url).toString();
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return fail('头像 URL 重定向目标无效', 'INVALID_ARGS');
|
|
253
|
+
}
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (!response.ok) {
|
|
257
|
+
clearTimeout(timeout);
|
|
258
|
+
return fail(`头像 URL 返回 HTTP ${response.status}`, 'UPLOAD_FAILED');
|
|
259
|
+
}
|
|
260
|
+
const length = Number(response.headers.get('content-length') || 0);
|
|
261
|
+
if (length > MAX_AVATAR_BYTES) {
|
|
262
|
+
clearTimeout(timeout);
|
|
263
|
+
return fail('头像大小超过 500KB 限制', 'INVALID_ARGS');
|
|
264
|
+
}
|
|
265
|
+
if (!response.body) {
|
|
266
|
+
clearTimeout(timeout);
|
|
267
|
+
return fail('头像 URL 返回空响应', 'INVALID_ARGS');
|
|
268
|
+
}
|
|
269
|
+
const reader = response.body.getReader();
|
|
270
|
+
const chunks = [];
|
|
271
|
+
let total = 0;
|
|
272
|
+
try {
|
|
273
|
+
while (true) {
|
|
274
|
+
const part = await reader.read();
|
|
275
|
+
if (part.done)
|
|
276
|
+
break;
|
|
277
|
+
const chunk = Buffer.from(part.value);
|
|
278
|
+
total += chunk.length;
|
|
279
|
+
if (total > MAX_AVATAR_BYTES) {
|
|
280
|
+
await reader.cancel();
|
|
281
|
+
return fail('头像大小超过 500KB 限制', 'INVALID_ARGS');
|
|
282
|
+
}
|
|
283
|
+
chunks.push(chunk);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
return fail(`头像 URL 下载失败: ${messageOf(e)}`, 'UPLOAD_FAILED');
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
clearTimeout(timeout);
|
|
291
|
+
reader.releaseLock();
|
|
292
|
+
}
|
|
293
|
+
return Buffer.concat(chunks, total);
|
|
294
|
+
}
|
|
295
|
+
return fail('头像 URL 重定向次数过多', 'INVALID_ARGS');
|
|
296
|
+
}
|
|
297
|
+
async function uploadValidatedAvatar(aid, buffer, mime, dimensions, opts) {
|
|
77
298
|
let agentMd;
|
|
78
299
|
try {
|
|
79
300
|
agentMd = await agentmdGet(aid, { aunPath: opts?.aunPath });
|
|
@@ -81,21 +302,18 @@ export async function uploadAvatar(aid, dataUrl, opts) {
|
|
|
81
302
|
catch (e) {
|
|
82
303
|
return fail(`agent.md 不存在或无法读取: ${messageOf(e)}`, classifyAgentMdReadError(e));
|
|
83
304
|
}
|
|
84
|
-
const ext = MIME_EXT[
|
|
305
|
+
const ext = MIME_EXT[mime] ?? 'img';
|
|
85
306
|
const objectKey = `shared/avatars/${crypto.randomUUID()}.${ext}`;
|
|
86
|
-
const
|
|
87
|
-
const tmpPath = path.join(tmpDir, `avatar.${ext}`);
|
|
307
|
+
const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
|
|
88
308
|
let uploaded = false;
|
|
89
309
|
try {
|
|
90
|
-
|
|
91
|
-
const uploadedAvatar = await storageUpload(aid, tmpPath, objectKey, {
|
|
310
|
+
const uploadedAvatar = await storageUpload(aid, buffer, objectKey, {
|
|
92
311
|
isPublic: true,
|
|
93
|
-
contentType:
|
|
312
|
+
contentType: mime,
|
|
94
313
|
aunPath: opts?.aunPath,
|
|
95
314
|
});
|
|
96
|
-
if (!uploadedAvatar.ok)
|
|
315
|
+
if (!uploadedAvatar.ok)
|
|
97
316
|
return fail(`上传到 AUN storage 失败: ${uploadedAvatar.error || 'unknown error'}`, 'UPLOAD_FAILED');
|
|
98
|
-
}
|
|
99
317
|
uploaded = true;
|
|
100
318
|
const publicUrl = uploadedAvatar.publicUrl ?? `https://${aid}/${uploadedAvatar.objectKey}`;
|
|
101
319
|
const updated = updateAgentMdFrontmatterAvatar(agentMd, publicUrl);
|
|
@@ -107,17 +325,58 @@ export async function uploadAvatar(aid, dataUrl, opts) {
|
|
|
107
325
|
return fail(`更新 agent.md 失败: ${messageOf(e)}`, 'UPLOAD_FAILED');
|
|
108
326
|
}
|
|
109
327
|
logger.info(`[avatar-upload] uploaded ${uploadedAvatar.objectKey} -> ${publicUrl}`);
|
|
110
|
-
return {
|
|
328
|
+
return {
|
|
329
|
+
ok: true,
|
|
330
|
+
publicUrl,
|
|
331
|
+
objectKey: uploadedAvatar.objectKey,
|
|
332
|
+
sha256,
|
|
333
|
+
mime,
|
|
334
|
+
width: dimensions.width,
|
|
335
|
+
height: dimensions.height,
|
|
336
|
+
aspectRatio: dimensions.width / dimensions.height,
|
|
337
|
+
};
|
|
111
338
|
}
|
|
112
339
|
catch (e) {
|
|
113
340
|
if (uploaded)
|
|
114
341
|
await cleanupUploadedAvatar(aid, objectKey, opts?.aunPath);
|
|
115
342
|
return fail(messageOf(e), 'UPLOAD_FAILED');
|
|
116
343
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
344
|
+
}
|
|
345
|
+
async function validateAvatarBuffer(buffer) {
|
|
346
|
+
const validated = await validateImage(buffer, {
|
|
347
|
+
maxSize: MAX_AVATAR_BYTES,
|
|
348
|
+
allowedMimes: ALLOWED_AVATAR_MIMES,
|
|
349
|
+
});
|
|
350
|
+
if (validated.mime === null)
|
|
351
|
+
return fail(`头像格式验证失败:${validated.reason}`, 'INVALID_ARGS');
|
|
352
|
+
const dimensions = validateAvatarDimensions(buffer, validated.mime);
|
|
353
|
+
if ('ok' in dimensions)
|
|
354
|
+
return dimensions;
|
|
355
|
+
return { ok: true, mime: validated.mime, dimensions };
|
|
356
|
+
}
|
|
357
|
+
/** Upload an image file after validating its bytes and real MIME type. */
|
|
358
|
+
export async function uploadAvatarFile(aid, filePath, opts) {
|
|
359
|
+
try {
|
|
360
|
+
const buffer = fs.readFileSync(filePath);
|
|
361
|
+
return await uploadAvatarBuffer(aid, buffer, opts);
|
|
122
362
|
}
|
|
363
|
+
catch (e) {
|
|
364
|
+
return fail(`头像文件读取失败: ${messageOf(e)}`, 'NOT_FOUND');
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/** Upload already-read image bytes; callers can use this after secure fd validation. */
|
|
368
|
+
export async function uploadAvatarBuffer(aid, buffer, opts) {
|
|
369
|
+
const validated = await validateAvatarBuffer(buffer);
|
|
370
|
+
if (!('dimensions' in validated))
|
|
371
|
+
return validated;
|
|
372
|
+
return uploadValidatedAvatar(aid, buffer, validated.mime, validated.dimensions, opts);
|
|
373
|
+
}
|
|
374
|
+
/** Download an HTTPS image with redirect/SSRF/size guards, then upload it. */
|
|
375
|
+
export async function uploadAvatarUrl(aid, url, opts) {
|
|
376
|
+
if (typeof url !== 'string' || !url.trim())
|
|
377
|
+
return fail('头像 URL 不能为空', 'INVALID_ARGS');
|
|
378
|
+
const downloaded = await downloadAvatarBuffer(url.trim());
|
|
379
|
+
if (!Buffer.isBuffer(downloaded))
|
|
380
|
+
return downloaded;
|
|
381
|
+
return uploadAvatarBuffer(aid, downloaded, opts);
|
|
123
382
|
}
|
|
@@ -449,7 +449,10 @@ export function resolveCliEntry(packageRoot) {
|
|
|
449
449
|
try {
|
|
450
450
|
const require = createRequire(path.join(packageRoot, 'package.json'));
|
|
451
451
|
const tsxUrl = pathToFileURL(require.resolve('tsx')).href;
|
|
452
|
-
|
|
452
|
+
const imports = process.platform === 'win32'
|
|
453
|
+
? ['--import', 'data:text/javascript,globalThis.process.geteuid=()=>-1', '--import', tsxUrl]
|
|
454
|
+
: ['--import', tsxUrl];
|
|
455
|
+
return { args: [...imports, sourceEntry], usingSource: true, entry: sourceEntry };
|
|
453
456
|
}
|
|
454
457
|
catch {
|
|
455
458
|
// tsx missing (e.g. `npm prune --production`): fall through to dist.
|
|
@@ -488,11 +491,37 @@ export function encodePath(projectPath) {
|
|
|
488
491
|
/**
|
|
489
492
|
* Cross-platform process liveness check.
|
|
490
493
|
*/
|
|
494
|
+
function readLinuxProcessState(pid) {
|
|
495
|
+
if (process.platform !== 'linux')
|
|
496
|
+
return null;
|
|
497
|
+
try {
|
|
498
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
499
|
+
// The comm field may contain ')', so use the final closing parenthesis.
|
|
500
|
+
const closeParen = stat.lastIndexOf(')');
|
|
501
|
+
if (closeParen < 0)
|
|
502
|
+
return null;
|
|
503
|
+
return stat.slice(closeParen + 2, closeParen + 3) || null;
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
// Keep the kill(0) fallback below for permission and procfs races.
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/** Return the Linux proc state (for example Z, D, S, or R), when available. */
|
|
511
|
+
export function getProcessState(pid) {
|
|
512
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
513
|
+
return null;
|
|
514
|
+
return readLinuxProcessState(pid);
|
|
515
|
+
}
|
|
491
516
|
export function isProcessRunning(pid) {
|
|
492
517
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
493
518
|
return false;
|
|
494
519
|
if (isWindows)
|
|
495
520
|
return isWindowsProcessRunning(pid);
|
|
521
|
+
// A zombie has exited; only its unreaped process-table entry remains. A
|
|
522
|
+
// kill(pid, 0) probe alone reports such an entry as alive on Linux.
|
|
523
|
+
if (readLinuxProcessState(pid) === 'Z')
|
|
524
|
+
return false;
|
|
496
525
|
try {
|
|
497
526
|
process.kill(pid, 0);
|
|
498
527
|
return true;
|
|
@@ -4,6 +4,14 @@ import { getPackageRoot, resolvePaths } from '../paths.js';
|
|
|
4
4
|
import { logger } from './logger.js';
|
|
5
5
|
import { isSandboxInitializationFailure } from '../core/permission/sandbox-runtime.js';
|
|
6
6
|
export const CLAUDE_STARTUP_ARGUMENTS_TOO_LONG = 'claude_startup_arguments_too_long';
|
|
7
|
+
/** A completed provider response exhausted its output budget, rather than a transport failure. */
|
|
8
|
+
export function isOutputTokenLimitError(error) {
|
|
9
|
+
const record = error && typeof error === 'object' ? error : undefined;
|
|
10
|
+
const message = String(record?.message ?? error ?? '');
|
|
11
|
+
return record?.code === 'max_output_tokens'
|
|
12
|
+
|| /^\s*max_output_tokens\s*$/i.test(message)
|
|
13
|
+
|| /\breason["']?\s*[:=]\s*["']?max_output_tokens\b/i.test(message);
|
|
14
|
+
}
|
|
7
15
|
/** Identify a Claude process-start failure without treating ordinary tool errors as startup failures. */
|
|
8
16
|
export function isClaudeStartupArgumentsTooLong(error) {
|
|
9
17
|
const code = error && typeof error === 'object' && 'code' in error
|
|
@@ -36,6 +44,7 @@ export var ErrorType;
|
|
|
36
44
|
ErrorType["AUTH_ERROR"] = "auth_error";
|
|
37
45
|
ErrorType["FILE_CORRUPT"] = "file_corrupt";
|
|
38
46
|
ErrorType["STREAM_ERROR"] = "stream_error";
|
|
47
|
+
ErrorType["MAX_OUTPUT_TOKENS"] = "max_output_tokens";
|
|
39
48
|
ErrorType["CONTEXT_TOO_LONG"] = "context_too_long";
|
|
40
49
|
ErrorType["MODEL_UNAVAILABLE"] = "model_unavailable";
|
|
41
50
|
ErrorType["SESSION_UNAVAILABLE"] = "session_unavailable";
|
|
@@ -78,6 +87,7 @@ const NON_INFRA_SUBTYPES = new Set([
|
|
|
78
87
|
'stop',
|
|
79
88
|
]);
|
|
80
89
|
const NON_INFRA_TERMINAL_REASONS = new Set([
|
|
90
|
+
'max_output_tokens',
|
|
81
91
|
'rapid_refill_breaker',
|
|
82
92
|
'tool_deferred',
|
|
83
93
|
'stop_hook_prevented',
|
|
@@ -279,6 +289,9 @@ function hasRetryableHttpStatus(text) {
|
|
|
279
289
|
}
|
|
280
290
|
export function classifyError(error) {
|
|
281
291
|
const msg = (error?.message || '').toLowerCase();
|
|
292
|
+
// The broad dictionary rule for "stream" must not hide a provider budget limit.
|
|
293
|
+
if (isOutputTokenLimitError(error))
|
|
294
|
+
return ErrorType.MAX_OUTPUT_TOKENS;
|
|
282
295
|
if (error?.code === 'TOPIC_BACKEND_UNAVAILABLE') {
|
|
283
296
|
return ErrorType.SESSION_UNAVAILABLE;
|
|
284
297
|
}
|
|
@@ -333,6 +346,9 @@ export function classifyError(error) {
|
|
|
333
346
|
* 注意:401 认证错误不可重试(API Key 无效不会因重试恢复)
|
|
334
347
|
*/
|
|
335
348
|
export function isRetryableError(error) {
|
|
349
|
+
// Replaying the same request does not increase its output budget.
|
|
350
|
+
if (isOutputTokenLimitError(error))
|
|
351
|
+
return false;
|
|
336
352
|
const msg = error?.message || String(error);
|
|
337
353
|
const lower = msg.toLowerCase();
|
|
338
354
|
// 字典优先 — 命中则直接返回
|
|
@@ -350,6 +366,11 @@ export function isRetryableError(error) {
|
|
|
350
366
|
return false;
|
|
351
367
|
}
|
|
352
368
|
export function getErrorMessage(error, terminalReason, includeEmoji = true) {
|
|
369
|
+
if (terminalReason === 'max_output_tokens' || isOutputTokenLimitError(error)) {
|
|
370
|
+
const prefix = includeEmoji ? '⚠️ ' : '';
|
|
371
|
+
return `${prefix}模型响应达到输出上限(max_output_tokens),本轮未完成。`
|
|
372
|
+
+ '请降低推理强度或提高模型服务的输出预算,然后在原会话发送“继续,先核对已完成操作,只完成剩余工作”;无需重发原任务。';
|
|
373
|
+
}
|
|
353
374
|
if (isClaudeStartupArgumentsTooLong(error)) {
|
|
354
375
|
const prefix = includeEmoji ? '⚠️ ' : '';
|
|
355
376
|
return `${prefix}当前 Claude 启动参数过长,任务未执行`;
|