evolcore 0.0.27 → 0.0.28

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.
@@ -6,6 +6,7 @@ import { promisify } from 'util';
6
6
  import fs from 'fs';
7
7
  import { getProcessStartTime, parseCimDate } from './process-introspect.js';
8
8
  import { decodeWindowsOutput } from './windows-output.js';
9
+ import { resolveTrustedWindowsCmdPath } from './windows-shell-trust.js';
9
10
  const execFileAsync = promisify(execFile);
10
11
  const CLI_PROBE_FAILURE_BACKOFF_MS = 5_000;
11
12
  const CLI_PROBE_PACKAGES = {
@@ -17,9 +18,70 @@ const cliPathCache = new Map();
17
18
  const cliInstallationCache = new Map();
18
19
  const cliProbeCache = new Map();
19
20
  export const isWindows = process.platform === 'win32';
21
+ // Keep this in sync with the Windows command-line metacharacters. The whole
22
+ // `/c` expression is passed as one verbatim argument so paths with spaces and
23
+ // shim-forwarded values such as `%`, `!`, `&`, and quotes stay data.
24
+ const WINDOWS_CMD_META_RE = /([()[\]%!^"`<>&|;, *?])/g;
25
+ function escapeWindowsCmdCommand(value) {
26
+ return value.replace(WINDOWS_CMD_META_RE, '^$1');
27
+ }
28
+ function escapeWindowsCmdArgument(value) {
29
+ let escaped = String(value);
30
+ escaped = escaped.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"');
31
+ escaped = escaped.replace(/(?=(\\+?)?)\1$/g, '$1$1');
32
+ escaped = `"${escaped}"`;
33
+ return escaped.replace(WINDOWS_CMD_META_RE, '^$1');
34
+ }
35
+ /** Build one argv-based CLI launch without consulting the parent terminal. */
36
+ export function buildCliLaunchCommand(executable, args, options = {}) {
37
+ const windows = options.isWindows ?? isWindows;
38
+ const extension = windows ? path.win32.extname(executable).toLowerCase() : '';
39
+ const shim = extension === '.cmd' || extension === '.bat';
40
+ if (!shim) {
41
+ return {
42
+ command: executable,
43
+ args,
44
+ resolvedPath: executable,
45
+ launcherKind: 'native-exe',
46
+ launcherPath: executable,
47
+ };
48
+ }
49
+ const resolveWindowsCmd = options.resolveWindowsCmd ?? resolveTrustedWindowsCmdPath;
50
+ const launcherPath = resolveWindowsCmd(options.comSpec);
51
+ if (!launcherPath) {
52
+ throw Object.assign(new Error('未找到受信任的系统 cmd.exe,已拒绝启动 Windows CLI shim'), {
53
+ code: 'EACCES',
54
+ path: executable,
55
+ });
56
+ }
57
+ const shellCommand = [
58
+ escapeWindowsCmdCommand(executable),
59
+ ...args.map(escapeWindowsCmdArgument),
60
+ ].join(' ');
61
+ return {
62
+ command: launcherPath,
63
+ args: ['/d', '/s', '/v:off', '/c', `"${shellCommand}"`],
64
+ resolvedPath: executable,
65
+ launcherKind: extension === '.cmd' ? 'cmd-shim' : 'bat-shim',
66
+ launcherPath,
67
+ windowsVerbatimArguments: true,
68
+ };
69
+ }
20
70
  /** Resolve a CLI executable without spawning where/which. */
21
71
  export function resolveProbePath(command) {
22
- const pathKey = `${command}\0${process.cwd()}\0${process.env.PATH ?? ''}\0${process.env.PATHEXT ?? ''}`;
72
+ const extensions = isWindows
73
+ ? (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(ext => /\.(com|exe|bat|cmd)$/i.test(ext))
74
+ : [''];
75
+ return resolveProbePathWithExtensions(command, extensions, 'executable');
76
+ }
77
+ /** Diagnose an incomplete npm installation without ever executing its PS1 shim. */
78
+ export function resolvePowerShellShimPath(command) {
79
+ if (!isWindows)
80
+ return null;
81
+ return resolveProbePathWithExtensions(command, ['.ps1'], 'powershell-shim');
82
+ }
83
+ function resolveProbePathWithExtensions(command, extensions, cacheKind) {
84
+ const pathKey = `${cacheKind}\0${command}\0${process.cwd()}\0${process.env.PATH ?? ''}\0${extensions.join(';')}`;
23
85
  const cachedPath = cliPathCache.get(pathKey);
24
86
  if (cachedPath && fs.existsSync(cachedPath))
25
87
  return cachedPath;
@@ -27,9 +89,6 @@ export function resolveProbePath(command) {
27
89
  const directories = (process.env.PATH ?? '').split(path.delimiter);
28
90
  if (isWindows)
29
91
  directories.unshift(process.cwd());
30
- const extensions = isWindows
31
- ? (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(ext => /\.(com|exe|bat|cmd)$/i.test(ext))
32
- : [''];
33
92
  for (const directory of directories) {
34
93
  for (const extension of extensions) {
35
94
  const candidate = path.resolve(directory.replace(/^"|"$/g, ''), command + extension);
@@ -98,20 +157,25 @@ export function invalidateCliProbes() {
98
157
  function cliProbeTimeouts(command, options) {
99
158
  return options.timeouts?.length ? options.timeouts : command === 'codex' ? [3_000, 15_000] : [3_000];
100
159
  }
101
- function prepareCliProbe(command, args) {
102
- const executable = resolveProbePath(command);
160
+ function prepareCliProbe(command, args, options = {}) {
161
+ const executable = options.executable ?? resolveProbePath(command);
103
162
  if (!executable)
104
163
  throw Object.assign(new Error(`${command} CLI not found`), { code: 'ENOENT' });
105
- const identity = `${probeInstallationIdentity(command, executable)}|${process.env.ComSpec ?? ''}`;
164
+ const launchCommand = buildCliLaunchCommand(executable, args);
165
+ const launch = launchCommand;
166
+ const identity = `${probeInstallationIdentity(command, executable)}|${cliFileIdentity(launch.launcherPath)}`;
106
167
  const key = JSON.stringify([command, args]);
107
168
  let entry = cliProbeCache.get(key);
108
169
  if (!entry || entry.identity !== identity || (entry.retryAt !== undefined && Date.now() >= entry.retryAt)) {
109
170
  entry = { identity };
110
171
  cliProbeCache.set(key, entry);
111
172
  }
112
- const shim = isWindows && /\.(cmd|bat)$/i.test(executable);
113
- return { entry, command: shim ? process.env.ComSpec ?? 'cmd.exe' : executable,
114
- args: shim ? ['/d', '/s', '/c', executable, ...args] : args };
173
+ return {
174
+ entry,
175
+ command: launchCommand.command,
176
+ args: launchCommand.args,
177
+ launch,
178
+ };
115
179
  }
116
180
  function failCliProbe(entry, error) {
117
181
  entry.error = error;
@@ -119,7 +183,7 @@ function failCliProbe(entry, error) {
119
183
  throw error;
120
184
  }
121
185
  export function probeCliSync(command, args, options = {}) {
122
- const request = prepareCliProbe(command, args);
186
+ const request = prepareCliProbe(command, args, options);
123
187
  if (request.entry.output !== undefined)
124
188
  return request.entry.output;
125
189
  if (request.entry.error !== undefined)
@@ -127,10 +191,14 @@ export function probeCliSync(command, args, options = {}) {
127
191
  for (const [index, timeout] of cliProbeTimeouts(command, options).entries()) {
128
192
  const startedAt = Date.now();
129
193
  try {
130
- return request.entry.output = execFileSync(request.command, request.args, { encoding: 'utf8', timeout, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
194
+ return request.entry.output = execFileSync(request.command, request.args, {
195
+ encoding: 'utf8', timeout, windowsHide: true,
196
+ windowsVerbatimArguments: request.launch.windowsVerbatimArguments,
197
+ stdio: ['ignore', 'pipe', 'pipe'],
198
+ });
131
199
  }
132
200
  catch (error) {
133
- options.onFailure?.(args, startedAt, error, timeout);
201
+ options.onFailure?.(args, startedAt, error, timeout, request.launch);
134
202
  if (error.code !== 'ETIMEDOUT' || index === cliProbeTimeouts(command, options).length - 1)
135
203
  return failCliProbe(request.entry, error);
136
204
  }
@@ -138,7 +206,7 @@ export function probeCliSync(command, args, options = {}) {
138
206
  throw new Error('CLI probe exhausted');
139
207
  }
140
208
  export async function probeCli(command, args, options = {}) {
141
- const request = prepareCliProbe(command, args);
209
+ const request = prepareCliProbe(command, args, options);
142
210
  if (request.entry.output !== undefined)
143
211
  return request.entry.output;
144
212
  if (request.entry.error !== undefined)
@@ -151,7 +219,10 @@ export async function probeCli(command, args, options = {}) {
151
219
  const startedAt = Date.now();
152
220
  try {
153
221
  const output = await new Promise((resolve, reject) => {
154
- const child = execFile(request.command, request.args, { encoding: 'utf8', timeout, windowsHide: true }, (error, stdout, stderr) => {
222
+ const child = execFile(request.command, request.args, {
223
+ encoding: 'utf8', timeout, windowsHide: true,
224
+ windowsVerbatimArguments: request.launch.windowsVerbatimArguments,
225
+ }, (error, stdout, stderr) => {
155
226
  if (error) {
156
227
  if (error.killed && error.signal === 'SIGTERM' && error.code == null)
157
228
  error.code = 'ETIMEDOUT';
@@ -166,7 +237,7 @@ export async function probeCli(command, args, options = {}) {
166
237
  return request.entry.output = output;
167
238
  }
168
239
  catch (error) {
169
- options.onFailure?.(args, startedAt, error, timeout);
240
+ options.onFailure?.(args, startedAt, error, timeout, request.launch);
170
241
  if (error.code !== 'ETIMEDOUT' || index === timeouts.length - 1)
171
242
  return failCliProbe(request.entry, error);
172
243
  }
@@ -8,11 +8,26 @@
8
8
  */
9
9
  import fs from 'fs';
10
10
  import path from 'path';
11
+ import os from 'os';
11
12
  import { execFile, execFileSync } from 'child_process';
12
13
  import { promisify } from 'util';
13
14
  import { getPackageRoot } from '../paths.js';
15
+ import { DEFAULT_RUNTIME_HOME_DIRNAME, MAIN_PACKAGE_NAME, RUNTIME_HOME_ENV, TARGET_PACKAGE_ROOT_ENV, WEB_PACKAGE_NAME, } from '../product.js';
14
16
  import { isWindows } from './cross-platform.js';
17
+ import { withFileLock } from './atomic-write.js';
15
18
  const execFileAsync = promisify(execFile);
19
+ function globalInstallLockPath() {
20
+ const home = process.env[RUNTIME_HOME_ENV] || path.join(os.homedir(), DEFAULT_RUNTIME_HOME_DIRNAME);
21
+ return path.join(home, 'data', 'npm-global-install.lock');
22
+ }
23
+ /**
24
+ * The restart monitor may execute from a staged copy of the package while
25
+ * upgrading the real installation. Keep code loading rooted at getPackageRoot
26
+ * but resolve install layout and local versions from the package being served.
27
+ */
28
+ function targetPackageRoot() {
29
+ return process.env[TARGET_PACKAGE_ROOT_ENV] || getPackageRoot();
30
+ }
16
31
  /** The preferred public registry for update checks. */
17
32
  export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org';
18
33
  function normalizeRegistry(registry = DEFAULT_NPM_REGISTRY) {
@@ -55,7 +70,7 @@ function resolveNpmInvocation() {
55
70
  * default. A command failure is handled separately by using the public source
56
71
  * directly, so a broken npm config cannot leave the upgrade without a retry.
57
72
  */
58
- async function resolveUpgradeRegistry(signal) {
73
+ export async function resolveUpgradeRegistry(signal) {
59
74
  const npm = resolveNpmInvocation();
60
75
  try {
61
76
  const result = await execFileAsync(npm.command, [...npm.argsPrefix, 'config', 'get', 'registry'], {
@@ -115,7 +130,7 @@ async function withGlobalInstallLock(action) {
115
130
  globalInstallTail = new Promise(resolve => { release = resolve; });
116
131
  await previous;
117
132
  try {
118
- await action();
133
+ await withFileLock(globalInstallLockPath(), action, 5 * 60_000);
119
134
  }
120
135
  finally {
121
136
  release();
@@ -191,6 +206,59 @@ export async function requireOptional(pkg, autoInstall = true) {
191
206
  return await import(pkg);
192
207
  }
193
208
  }
209
+ const PACKAGE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
210
+ export function isPackageVersion(value) {
211
+ if (typeof value !== 'string' || value.length > 256)
212
+ return false;
213
+ const match = PACKAGE_VERSION.exec(value);
214
+ return !!match && (!match[4] || match[4].split('.').every(id => !/^\d+$/.test(id) || id === '0' || !id.startsWith('0')));
215
+ }
216
+ export function normalizeVersionRegistry(value) {
217
+ if (typeof value !== 'string')
218
+ return null;
219
+ try {
220
+ const url = new URL(value);
221
+ if (!['https:', 'http:'].includes(url.protocol) || !url.hostname
222
+ || url.username || url.password || url.search || url.hash)
223
+ return null;
224
+ return url.href.replace(/\/+$/, '');
225
+ }
226
+ catch {
227
+ return null;
228
+ }
229
+ }
230
+ export function comparePackageVersions(a, b) {
231
+ if (!isPackageVersion(a) || !isPackageVersion(b))
232
+ throw new Error('Invalid package version');
233
+ const left = PACKAGE_VERSION.exec(a);
234
+ const right = PACKAGE_VERSION.exec(b);
235
+ for (let i = 1; i <= 3; i++) {
236
+ if (BigInt(left[i]) < BigInt(right[i]))
237
+ return -1;
238
+ if (BigInt(left[i]) > BigInt(right[i]))
239
+ return 1;
240
+ }
241
+ if (!left[4] || !right[4])
242
+ return left[4] === right[4] ? 0 : left[4] ? -1 : 1;
243
+ const la = left[4].split('.');
244
+ const ra = right[4].split('.');
245
+ for (let i = 0; i < Math.max(la.length, ra.length); i++) {
246
+ if (la[i] === ra[i])
247
+ continue;
248
+ if (la[i] === undefined)
249
+ return -1;
250
+ if (ra[i] === undefined)
251
+ return 1;
252
+ const ln = /^\d+$/.test(la[i]);
253
+ const rn = /^\d+$/.test(ra[i]);
254
+ if (ln && rn)
255
+ return BigInt(la[i]) < BigInt(ra[i]) ? -1 : 1;
256
+ if (ln !== rn)
257
+ return ln ? -1 : 1;
258
+ return la[i] < ra[i] ? -1 : 1;
259
+ }
260
+ return 0;
261
+ }
194
262
  /**
195
263
  * 比较两个 semver 版本号 (a.b.c 格式)
196
264
  * 返回 -1 (a < b), 0 (a == b), 1 (a > b)
@@ -216,12 +284,12 @@ export function compareVersions(a, b) {
216
284
  * 而 npm link 指向项目源码目录,其父目录不是 node_modules。
217
285
  */
218
286
  export function isLinkedInstall() {
219
- const pkgRoot = getPackageRoot();
287
+ const pkgRoot = targetPackageRoot();
220
288
  return path.basename(path.dirname(pkgRoot)) !== 'node_modules';
221
289
  }
222
290
  /** 获取本地 package.json 中的版本号 */
223
291
  export function getLocalVersion() {
224
- const pkgPath = path.join(getPackageRoot(), 'package.json');
292
+ const pkgPath = path.join(targetPackageRoot(), 'package.json');
225
293
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
226
294
  return pkg.version;
227
295
  }
@@ -276,6 +344,53 @@ async function fetchLatestVersion(pkg = 'evolcore', timeoutMs = 10000, signal, r
276
344
  signal?.removeEventListener('abort', abort);
277
345
  }
278
346
  }
347
+ /** Query an already selected source, retaining npm proxy/CA/auth behavior. */
348
+ export async function queryPackageVersion(pkg, selection, signal) {
349
+ const registry = normalizeVersionRegistry(selection.registry);
350
+ if (!registry)
351
+ return null;
352
+ const version = selection.useNpmCli
353
+ ? await checkLatestVersionViaNpm(pkg, 10000, signal, registry)
354
+ : await fetchLatestVersion(pkg, 10000, signal, registry);
355
+ return isPackageVersion(version) ? { version, registry } : null;
356
+ }
357
+ /** No registry lookup: callers must validate cache freshness before each install. */
358
+ export async function installExactPackageVersion(pkg, target, registry, resolveInstalled) {
359
+ if (![MAIN_PACKAGE_NAME, WEB_PACKAGE_NAME].includes(pkg)
360
+ || !isPackageVersion(target) || normalizeVersionRegistry(registry) !== registry) {
361
+ return { status: 'failed', error: 'Invalid cached install target' };
362
+ }
363
+ if (isLinkedInstall())
364
+ return { status: 'skipped' };
365
+ let result = { status: 'skipped' };
366
+ await withGlobalInstallLock(async () => {
367
+ const installed = resolveInstalled();
368
+ if (!installed || !isPackageVersion(installed.version))
369
+ return;
370
+ const from = installed.version;
371
+ if (comparePackageVersions(from, target) >= 0) {
372
+ result = { status: 'no-update', from };
373
+ return;
374
+ }
375
+ for (let attempt = 0; attempt < 2; attempt++) {
376
+ try {
377
+ await npmInstallGlobal(`${pkg}@${target}`, { registry });
378
+ const actual = resolveInstalled()?.version;
379
+ if (!actual || !isPackageVersion(actual) || comparePackageVersions(actual, target) < 0) {
380
+ throw new Error('Installed version did not reach the cached target');
381
+ }
382
+ result = { status: 'upgraded', from, to: actual };
383
+ return;
384
+ }
385
+ catch {
386
+ // npm stderr can contain private registry credentials. Persist a safe
387
+ // diagnostic; npm's existing permission/transport behavior is retained.
388
+ result = { status: 'failed', from, to: target, error: 'Exact package installation or version verification failed' };
389
+ }
390
+ }
391
+ });
392
+ return result;
393
+ }
279
394
  /**
280
395
  * Read the current npm registry first. If that read succeeds, npm itself is
281
396
  * used for the version query so proxy/CA/auth settings are preserved. If the
@@ -0,0 +1,244 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getPackageRoot, resolvePaths } from '../paths.js';
4
+ import { MAIN_PACKAGE_NAME, WEB_PACKAGE_NAME, TARGET_PACKAGE_ROOT_ENV } from '../product.js';
5
+ import { getLocalVersion, isLinkedInstall, resolveGlobalPkg, resolveUpgradeRegistry, queryPackageVersion, installExactPackageVersion, } from './npm-ops.js';
6
+ import { comparePackageVersions, isPackageVersion, normalizeVersionRegistry } from './npm-ops.js';
7
+ import { atomicReplaceJson, withFileLock } from './atomic-write.js';
8
+ export const VERSION_INFO_TTL_MS = 24 * 60 * 60 * 1000;
9
+ export const VERSION_INFO_PACKAGES = [MAIN_PACKAGE_NAME, WEB_PACKAGE_NAME, '@agentunion/fastaun'];
10
+ export const AUTO_UPGRADE_PACKAGES = [MAIN_PACKAGE_NAME, WEB_PACKAGE_NAME];
11
+ const empty = () => ({ $schema_version: 1, packages: {} });
12
+ const timestamp = (value) => typeof value === 'string' ? Date.parse(value) : NaN;
13
+ const allowed = (name) => VERSION_INFO_PACKAGES.includes(name);
14
+ const filePath = () => path.join(resolvePaths().dataDir, 'package-version-info.json');
15
+ const writeLockPath = () => `${filePath()}.lock`;
16
+ /** Read-only: corrupt/unknown formats never recover fields from partial data. */
17
+ export function readPackageVersionInfo() {
18
+ try {
19
+ const raw = JSON.parse(fs.readFileSync(filePath(), 'utf8'));
20
+ if (raw?.$schema_version !== 1 || !raw.packages || Array.isArray(raw.packages)
21
+ || typeof raw.packages !== 'object' || Object.keys(raw.packages).some(name => !allowed(name)))
22
+ return empty();
23
+ const result = empty();
24
+ for (const [name, value] of Object.entries(raw.packages)) {
25
+ const entry = value;
26
+ if (!entry || entry.packageName !== name || !['ok', 'unavailable', 'skipped', 'not-installed'].includes(entry.lastAttemptStatus)
27
+ || (entry.currentVersion !== null && !isPackageVersion(entry.currentVersion))
28
+ || (entry.latestVersion != null && !isPackageVersion(entry.latestVersion))
29
+ || (entry.registry !== undefined && normalizeVersionRegistry(entry.registry) !== entry.registry))
30
+ continue;
31
+ result.packages[name] = entry;
32
+ }
33
+ return result;
34
+ }
35
+ catch {
36
+ return empty();
37
+ }
38
+ }
39
+ /** Cooperating readers pause while a writer owns the Windows replace gate. */
40
+ export async function readPackageVersionInfoConsistent(timeoutMs = 30000) {
41
+ const deadline = Date.now() + timeoutMs;
42
+ while (fs.existsSync(writeLockPath())) {
43
+ if (Date.now() >= deadline)
44
+ return readPackageVersionInfo();
45
+ await new Promise(resolve => setTimeout(resolve, 15));
46
+ }
47
+ return readPackageVersionInfo();
48
+ }
49
+ export function isPackageVersionInfoFresh(entry, now = Date.now(), ttlMs = VERSION_INFO_TTL_MS, registry) {
50
+ if (!entry || !allowed(entry.packageName) || !isPackageVersion(entry.currentVersion)
51
+ || !isPackageVersion(entry.latestVersion) || !entry.registry
52
+ || normalizeVersionRegistry(entry.registry) !== entry.registry
53
+ || (registry !== undefined && entry.registry !== registry))
54
+ return false;
55
+ const checked = timestamp(entry.checkedAt);
56
+ const observed = timestamp(entry.currentVersionObservedAt);
57
+ return Number.isFinite(checked) && checked <= now && now - checked <= ttlMs
58
+ && Number.isFinite(observed) && observed <= now;
59
+ }
60
+ /** Snapshot groups have separate clocks so a stale query cannot undo an install. */
61
+ export async function mergePackageVersionInfo(patch) {
62
+ if (!allowed(patch.packageName))
63
+ throw new Error('Unmanaged package');
64
+ if (patch.currentVersion !== null && !isPackageVersion(patch.currentVersion))
65
+ throw new Error('Invalid installed version');
66
+ if (patch.checkedAt && (!isPackageVersion(patch.latestVersion)
67
+ || !patch.registry || normalizeVersionRegistry(patch.registry) !== patch.registry))
68
+ throw new Error('Invalid successful snapshot');
69
+ const file = filePath();
70
+ return withFileLock(writeLockPath(), async () => {
71
+ const data = readPackageVersionInfo();
72
+ const previous = data.packages[patch.packageName];
73
+ const entry = previous ? { ...previous } : {
74
+ packageName: patch.packageName, currentVersion: null, lastAttemptStatus: patch.lastAttemptStatus,
75
+ };
76
+ const newer = (next, old) => Number.isFinite(timestamp(next))
77
+ && (!Number.isFinite(timestamp(old)) || timestamp(old) > Date.now() || timestamp(next) >= timestamp(old));
78
+ if (newer(patch.currentVersionObservedAt, entry.currentVersionObservedAt)) {
79
+ entry.currentVersion = patch.currentVersion;
80
+ entry.currentVersionObservedAt = patch.currentVersionObservedAt;
81
+ }
82
+ if (newer(patch.checkedAt, entry.checkedAt)) {
83
+ entry.latestVersion = patch.latestVersion;
84
+ entry.checkedAt = patch.checkedAt;
85
+ entry.registry = patch.registry;
86
+ }
87
+ if (newer(patch.lastAttemptAt, entry.lastAttemptAt)) {
88
+ entry.lastAttemptStatus = patch.lastAttemptStatus;
89
+ entry.lastAttemptAt = patch.lastAttemptAt;
90
+ if (patch.lastError)
91
+ entry.lastError = patch.lastError;
92
+ else
93
+ delete entry.lastError;
94
+ }
95
+ if (patch.lastUpgrade && newer(patch.lastUpgrade.at, entry.lastUpgrade?.at))
96
+ entry.lastUpgrade = patch.lastUpgrade;
97
+ data.packages[patch.packageName] = entry;
98
+ await atomicReplaceJson(file, data);
99
+ return entry;
100
+ });
101
+ }
102
+ export function resolveVersionInfoPackage(name) {
103
+ if (!allowed(name))
104
+ throw new Error('Unmanaged package');
105
+ const root = process.env[TARGET_PACKAGE_ROOT_ENV] || getPackageRoot();
106
+ if (name === MAIN_PACKAGE_NAME)
107
+ return { version: getLocalVersion(), path: path.join(root, 'package.json') };
108
+ if (name === WEB_PACKAGE_NAME)
109
+ return resolveGlobalPkg(name);
110
+ const file = path.join(root, 'node_modules', '@agentunion', 'fastaun', 'package.json');
111
+ try {
112
+ return { version: JSON.parse(fs.readFileSync(file, 'utf8')).version, path: file };
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
118
+ function hasUpdate(entry, registry, now = Date.now()) {
119
+ return isPackageVersionInfoFresh(entry, now, VERSION_INFO_TTL_MS, registry)
120
+ && comparePackageVersions(entry.currentVersion, entry.latestVersion) < 0;
121
+ }
122
+ async function checkPackage(name, force, options) {
123
+ if (!allowed(name))
124
+ throw new Error('Unmanaged package');
125
+ const now = options.now ?? Date.now();
126
+ const at = new Date(now).toISOString();
127
+ const old = (await readPackageVersionInfoConsistent()).packages[name];
128
+ const observed = {
129
+ packageName: name, currentVersion: null, currentVersionObservedAt: at,
130
+ lastAttemptStatus: 'unavailable',
131
+ };
132
+ const log = (message) => options.log?.(`[${options.source ?? 'version-check'}] ${name}: ${message}`);
133
+ let source = 'refresh';
134
+ try {
135
+ const linked = isLinkedInstall();
136
+ const local = linked && name !== MAIN_PACKAGE_NAME ? null : resolveVersionInfoPackage(name);
137
+ if (local && !isPackageVersion(local.version))
138
+ throw new Error('Invalid installed package version');
139
+ observed.currentVersion = local?.version ?? null;
140
+ if (linked)
141
+ observed.lastAttemptStatus = 'skipped';
142
+ else if (!local)
143
+ observed.lastAttemptStatus = 'not-installed';
144
+ else {
145
+ const selection = options.selection ?? await resolveUpgradeRegistry(options.signal);
146
+ const registry = normalizeVersionRegistry(selection.registry);
147
+ if (!registry)
148
+ throw new Error('Registry configuration is not safe for version caching');
149
+ if (!force && isPackageVersionInfoFresh(old, now, VERSION_INFO_TTL_MS, registry)) {
150
+ source = 'cache';
151
+ observed.lastAttemptStatus = 'ok';
152
+ const entry = await mergePackageVersionInfo(observed);
153
+ log(`Using cached version information (age ${Math.floor((now - timestamp(old.checkedAt)) / 60000)}m)`);
154
+ return { packageName: name, status: 'ok', source, entry, hasUpdate: hasUpdate(entry, registry, now) };
155
+ }
156
+ log(force ? 'Refreshing version information' : 'Version information missing/invalid/expired; refreshing');
157
+ const checked = await queryPackageVersion(name, selection, options.signal);
158
+ if (!checked)
159
+ throw new Error('Version registry unavailable or returned an invalid version');
160
+ observed.latestVersion = checked.version;
161
+ observed.registry = checked.registry;
162
+ // Use request start, not completion: an older slow request cannot replace
163
+ // a later successful request, and query time consumes the TTL.
164
+ observed.checkedAt = at;
165
+ observed.lastAttemptStatus = 'ok';
166
+ }
167
+ }
168
+ catch {
169
+ observed.lastAttemptStatus = 'unavailable';
170
+ observed.lastError = 'Version refresh failed; continuing with current version';
171
+ log(observed.lastError);
172
+ }
173
+ observed.lastAttemptAt = at;
174
+ let entry = { ...old, ...observed };
175
+ try {
176
+ entry = await mergePackageVersionInfo(observed);
177
+ }
178
+ catch {
179
+ log('Version information could not be saved; continuing with current version');
180
+ return { packageName: name, source, entry, status: 'unavailable', hasUpdate: false, error: 'Version information could not be saved' };
181
+ }
182
+ return {
183
+ packageName: name, source, entry, status: observed.lastAttemptStatus,
184
+ hasUpdate: observed.lastAttemptStatus === 'ok' && hasUpdate(entry, observed.registry, now),
185
+ ...(observed.lastError ? { error: observed.lastError } : {}),
186
+ };
187
+ }
188
+ export async function refreshPackageVersionInfo(name, options = {}) {
189
+ return checkPackage(name, true, options);
190
+ }
191
+ export async function ensureFreshPackageVersionInfo(names = AUTO_UPGRADE_PACKAGES, options = {}) {
192
+ const selection = options.selection ?? (isLinkedInstall() ? undefined : await resolveUpgradeRegistry(options.signal));
193
+ return Promise.all(names.map(name => checkPackage(name, false, { ...options, selection })));
194
+ }
195
+ export async function refreshManagedPackageVersions(names = AUTO_UPGRADE_PACKAGES, options = {}) {
196
+ const selection = options.selection ?? (isLinkedInstall() ? undefined : await resolveUpgradeRegistry(options.signal));
197
+ return Promise.all(names.map(name => checkPackage(name, true, { ...options, selection })));
198
+ }
199
+ export async function recordPackageUpgrade(name, upgrade, log) {
200
+ try {
201
+ await mergePackageVersionInfo({
202
+ packageName: name, currentVersion: resolveVersionInfoPackage(name)?.version ?? null,
203
+ currentVersionObservedAt: new Date().toISOString(), lastAttemptStatus: 'unavailable', lastUpgrade: upgrade,
204
+ });
205
+ }
206
+ catch {
207
+ log?.(`${name}: Failed to record install outcome`);
208
+ }
209
+ }
210
+ /** Called only by the lifecycle coordinator, after shutdown. Never queries registry. */
211
+ export async function applyCachedPackageUpgrades(names = AUTO_UPGRADE_PACKAGES, options = {}) {
212
+ if (names.some(name => !AUTO_UPGRADE_PACKAGES.includes(name)))
213
+ throw new Error('Package is not automatically managed');
214
+ if (isLinkedInstall())
215
+ return names.map(() => ({ status: 'skipped' }));
216
+ const results = [];
217
+ for (const name of names) {
218
+ let result = { status: 'skipped' };
219
+ if (!AUTO_UPGRADE_PACKAGES.includes(name))
220
+ throw new Error('Package is not automatically managed');
221
+ try {
222
+ const entry = (await readPackageVersionInfoConsistent(0)).packages[name];
223
+ const registry = entry?.registry ? normalizeVersionRegistry(entry.registry) : null;
224
+ if (!registry || !isPackageVersionInfoFresh(entry, Date.now(), VERSION_INFO_TTL_MS, registry)) {
225
+ result = { status: 'skipped', error: 'No valid cached version information' };
226
+ }
227
+ else {
228
+ options.log?.(`Applying cached version information: ${name}@${entry.latestVersion}`);
229
+ result = await installExactPackageVersion(name, entry.latestVersion, registry, () => resolveVersionInfoPackage(name));
230
+ }
231
+ }
232
+ catch {
233
+ result = { status: 'failed', error: 'Cached upgrade could not acquire installation lock or read installed version' };
234
+ }
235
+ if (result.status === 'upgraded' || result.status === 'failed') {
236
+ await recordPackageUpgrade(name, {
237
+ from: result.from, to: result.to, at: new Date().toISOString(),
238
+ status: result.status === 'upgraded' ? 'installed' : 'failed', error: result.error,
239
+ }, options.log);
240
+ }
241
+ results.push(result);
242
+ }
243
+ return results;
244
+ }
@@ -36,6 +36,10 @@ export function isTrustedShellSignature(kind, evidence) {
36
36
  function windowsSystemRoot() {
37
37
  return process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows';
38
38
  }
39
+ /** Return the only cmd.exe that EvolCore is willing to use as a shell carrier. */
40
+ export function windowsSystemCmdPath() {
41
+ return path.win32.join(windowsSystemRoot(), 'System32', 'cmd.exe');
42
+ }
39
43
  function systemPowerShellPath() {
40
44
  return path.win32.join(windowsSystemRoot(), 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
41
45
  }
@@ -195,6 +199,39 @@ export function isTrustedWindowsShellExecutable(executable, kind) {
195
199
  }
196
200
  return trusted;
197
201
  }
202
+ /**
203
+ * Resolve an inherited ComSpec to the system cmd.exe and authenticate it.
204
+ * npm shims are not signed Microsoft binaries, so this helper intentionally
205
+ * validates only the interpreter and never the `.cmd`/`.bat` target.
206
+ */
207
+ export function resolveTrustedWindowsCmdPath(comSpec) {
208
+ if (process.platform !== 'win32')
209
+ return comSpec || 'cmd.exe';
210
+ const systemCmd = windowsSystemCmdPath();
211
+ const candidate = (comSpec || process.env.ComSpec || process.env.COMSPEC || systemCmd).trim();
212
+ if (!candidate)
213
+ return undefined;
214
+ let candidatePath = candidate;
215
+ if (!isAbsoluteWindowsPath(candidatePath)) {
216
+ if (/^cmd(?:\.exe)?$/i.test(candidatePath))
217
+ candidatePath = systemCmd;
218
+ else
219
+ return undefined;
220
+ }
221
+ let candidateReal;
222
+ let systemReal;
223
+ try {
224
+ candidateReal = fs.realpathSync.native(candidatePath);
225
+ systemReal = fs.realpathSync.native(systemCmd);
226
+ }
227
+ catch {
228
+ return undefined;
229
+ }
230
+ if (path.win32.normalize(candidateReal).toLowerCase() !== path.win32.normalize(systemReal).toLowerCase()) {
231
+ return undefined;
232
+ }
233
+ return isTrustedWindowsShellExecutable(systemReal, 'cmd') ? systemReal : undefined;
234
+ }
198
235
  /** Test hook: clear cached signature decisions after a fixture is replaced. */
199
236
  export function clearWindowsShellTrustCache() {
200
237
  signatureCache.clear();
@@ -55,6 +55,14 @@ AUN keystore seed 由 EvolCore 使用固定应用值管理,不再提供配置
55
55
  - **群 @ 处理策略**:`mention_mode=disabled|mention-only` 是群级配置,只决定哪些群消息进入 Agent LLM 上下文,不影响 AUN 协议层投递;被过滤消息仍应本地存档。
56
56
  - **身份体系**:对端以 AID 标识,关系层 peerKey 形如 `aun#alice.aid.pub`。
57
57
 
58
+ ### 私聊最终回复标题
59
+
60
+ AUN 私聊中,`result.text` 的 `isFinal=true` 会在 `payload.text` 正文前添加 `✅ 最终回复:`。AUN 文本客户端按正文展示,标题随 outbox 上下文持久化,断线恢复发送时仍保留。群聊、中间文本、命令回执及错误消息不自动添加该标题。
61
+
62
+ Interactive 私聊的最终正文保留到本轮结束后统一发送,避免定时 flush 提前当作中间文本发出。Codex 明确标记的 commentary 可按原定时窗口发送;没有阶段标记的正文等到下一文本块、工具调用或本轮结束后确定发送方式。标题和正文之间留一个空行,正文已经以同一标题开头时不重复添加,也不额外发送 `payload.title`。
63
+
64
+ 标题仅表示最终回复的展示信息,任务完成或失败仍由 `task.status` 表达。
65
+
58
66
  ### 出站关注状态
59
67
 
60
68
  AUN runner 状态和 action card 可携带顶层 `attention`,值只能是以下四种之一:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evolcore",
3
- "version": "0.0.27",
3
+ "version": "0.0.28",
4
4
  "description": "AI Agent gateway connecting Claude, Codex, Gemini, and the bundled ecagent runner to messaging channels with multi-project session management",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",