@yeaft/webchat-agent 1.0.674 → 1.0.675

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.
@@ -104,14 +104,14 @@ async function sendNow(msg, queuedSocket) {
104
104
  const socket = ctx.ws;
105
105
  if (!socket || socket.readyState !== WebSocket.OPEN) return bufferMessage(msg, 'Disconnected');
106
106
  // File transfers are request/connection scoped, unlike replayable chat output.
107
- if (['file_content_chunk', 'video_chunk'].includes(msg.type) && queuedSocket !== socket) return 'dropped';
107
+ if (['file_content_chunk', 'video_chunk', 'upgrade_agent_ack'].includes(msg.type) && queuedSocket !== socket) return 'dropped';
108
108
  const payload = ctx.serverEncryptionRequired && ctx.sessionKey
109
109
  ? await encrypt(msg, ctx.sessionKey) : msg;
110
110
  if (socket !== ctx.ws || socket.readyState !== WebSocket.OPEN) {
111
111
  return bufferMessage(msg, 'Connection changed');
112
112
  }
113
113
  const data = JSON.stringify(payload);
114
- if (['file_content_chunk', 'video_chunk'].includes(msg.type)) return sendTransferChunk(socket, data);
114
+ if (['file_content_chunk', 'video_chunk', 'upgrade_agent_ack'].includes(msg.type)) return sendTransferChunk(socket, data);
115
115
  socket.send(data);
116
116
  return 'sent';
117
117
  }
@@ -161,7 +161,7 @@ export async function sendToServer(msg) {
161
161
  return 'dropped';
162
162
  }
163
163
  const promise = new Promise((resolve, reject) => {
164
- const connectionScoped = ['file_content_chunk', 'video_chunk'].includes(msg.type);
164
+ const connectionScoped = ['file_content_chunk', 'video_chunk', 'upgrade_agent_ack'].includes(msg.type);
165
165
  ctx.outboundSendQueue.push({ msg, bytes, resolve, reject, socket: connectionScoped ? ctx.ws : null });
166
166
  ctx.outboundSendQueueBytes = Number(ctx.outboundSendQueueBytes || 0) + bytes;
167
167
  });
@@ -21,6 +21,7 @@ import {
21
21
  } from '../upgrade-command.js';
22
22
  import { sendToServer } from './buffer.js';
23
23
  import { stopAgentHeartbeat } from './heartbeat.js';
24
+ import { createUnixUpgradeRun, getUnixUpgradeLockPath, launchUnixUpgrade, shellQuote } from '../unix-upgrade.js';
24
25
 
25
26
  /**
26
27
  * Resolve the local service instance id for this running agent. Each agent
@@ -63,8 +64,11 @@ export function isUpgradeAvailable(currentVersion, latestVersion) {
63
64
  }
64
65
 
65
66
  // Shared cleanup logic for restart/upgrade
66
- function cleanupAndExit(exitCode) {
67
+ function cleanupAndExit(exitCode, { maxCleanupMs = null } = {}) {
67
68
  setTimeout(async () => {
69
+ // The updater waits for this process. Do not let an unbounded async cleanup
70
+ // outlive the handoff and strand an on-failure service after exit(0).
71
+ if (maxCleanupMs) setTimeout(() => process.exit(exitCode), maxCleanupMs).unref();
68
72
  for (const [, term] of ctx.terminals) {
69
73
  if (term.pty) { try { term.pty.kill(); } catch {} }
70
74
  if (term.timer) clearTimeout(term.timer);
@@ -282,7 +286,7 @@ export async function handleUpgradeAgent(correlation = {}) {
282
286
  }
283
287
 
284
288
  // 清理并退出,让升级脚本接管
285
- cleanupAndExit(0);
289
+ cleanupAndExit(0, { maxCleanupMs: 20_000 });
286
290
  } catch (e) {
287
291
  console.error('[Agent] Upgrade failed:', e.message);
288
292
  await sendUpgradeAck({ success: false, error: e.message });
@@ -416,11 +420,12 @@ export function buildUnixUpgradeScript({
416
420
  safePath,
417
421
  instanceId = DEFAULT_INSTANCE_ID,
418
422
  isDarwin = platform() === 'darwin',
423
+ run,
424
+ lockPath,
425
+ nodePath = process.execPath,
426
+ isSystemd = existsSync(join(homedir(), '.config', 'systemd', 'user', `${getServiceName(instanceId)}.service`)),
419
427
  }) {
420
- const shPath = join(configDir, 'upgrade.sh');
421
428
  const serviceName = getServiceName(instanceId);
422
- const systemdUnitPath = join(homedir(), '.config', 'systemd', 'user', `${serviceName}.service`);
423
- const isSystemd = existsSync(systemdUnitPath);
424
429
  const plistPath = getLaunchdPlistPath(instanceId);
425
430
  const isLaunchd = isDarwin && existsSync(plistPath);
426
431
  const cwd = isGlobalInstall ? undefined : installDir;
@@ -428,16 +433,47 @@ export function buildUnixUpgradeScript({
428
433
  const shLines = [
429
434
  '#!/bin/bash',
430
435
  `PID=${pid}`,
431
- `PKG="${pkgName}@${targetVersion}"`,
432
- `NPM="${npmPath}"`,
433
- `LOGFILE="${join(configDir, 'logs', 'upgrade.log')}"`,
434
- `export PATH="${safePath}"`,
436
+ `PKG=${shellQuote(`${pkgName}@${targetVersion}`)}`,
437
+ `NPM=${shellQuote(npmPath)}`,
438
+ `LOGFILE=${shellQuote(join(configDir, 'logs', 'upgrade.log'))}`,
439
+ `export PATH=${shellQuote(safePath)}`,
435
440
  '',
436
441
  '# Redirect all output to log file',
437
- 'exec > "$LOGFILE" 2>&1',
442
+ 'exec >> "$LOGFILE" 2>&1 || exit 1',
438
443
  'echo "[Upgrade] Started at $(date)"',
439
444
  '',
440
- ...(cwd ? [`INSTALL_DIR="${cwd}"`] : []),
445
+ ...(cwd ? [`INSTALL_DIR=${shellQuote(cwd)}`] : []),
446
+ ...(run ? [
447
+ `RUN_DIR=${shellQuote(run.dir)}`,
448
+ `READY=${shellQuote(run.readyPath)}`,
449
+ `AUTHORIZED=${shellQuote(run.authorizePath)}`,
450
+ `ACCEPTED=${shellQuote(run.acceptedPath)}`,
451
+ `CANCELLED=${shellQuote(run.cancelPath)}`,
452
+ `ERROR_FILE=${shellQuote(run.errorPath)}`,
453
+ ...(lockPath ? [
454
+ `LOCK=${shellQuote(lockPath)}`,
455
+ 'if ! command -v flock >/dev/null || ! exec 9>"$LOCK"; then',
456
+ ' echo "Cannot acquire installation upgrade lock; Agent remains online" > "$ERROR_FILE"',
457
+ ' exit 1',
458
+ 'fi',
459
+ 'if ! flock -n 9; then',
460
+ ' echo "Another Agent is upgrading this shared installation; retry after it finishes" > "$ERROR_FILE"',
461
+ ' exit 1',
462
+ 'fi',
463
+ ] : []),
464
+ 'echo "$$" > "$READY" || exit 1',
465
+ 'COUNT=0',
466
+ 'while [ ! -f "$AUTHORIZED" ]; do',
467
+ ' if [ -f "$CANCELLED" ] || ! kill -0 "$PID" 2>/dev/null || [ "$COUNT" -ge 60 ]; then',
468
+ ' echo "[Upgrade] Handoff cancelled; installation unchanged"',
469
+ ' exit 1',
470
+ ' fi',
471
+ ' COUNT=$((COUNT+1))',
472
+ ' sleep 1',
473
+ 'done',
474
+ '[ ! -f "$CANCELLED" ] || exit 1',
475
+ 'touch "$ACCEPTED" || exit 1',
476
+ ] : []),
441
477
  '',
442
478
  '# Wait for current process to exit',
443
479
  'COUNT=0',
@@ -445,10 +481,11 @@ export function buildUnixUpgradeScript({
445
481
  ' COUNT=$((COUNT+1))',
446
482
  ' if [ $COUNT -ge 30 ]; then',
447
483
  ' echo "[Upgrade] Timeout waiting for PID $PID to exit"',
448
- ' break',
484
+ ' exit 1',
449
485
  ' fi',
450
486
  ' sleep 2',
451
487
  'done',
488
+ ...(run ? ['[ ! -f "$CANCELLED" ] || exit 1'] : []),
452
489
  '',
453
490
  ];
454
491
 
@@ -468,7 +505,10 @@ export function buildUnixUpgradeScript({
468
505
  'export DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-unix:path=/run/user/$YEAFT_UID/bus}"',
469
506
  '',
470
507
  '# Stop systemd service to prevent restart loop',
471
- `systemctl --user stop "${serviceName}" 2>/dev/null`,
508
+ `if ! systemctl --user stop "${serviceName}"; then`,
509
+ ' echo "[Upgrade] Could not stop selected service; refusing to modify installation"',
510
+ ' exit 1',
511
+ 'fi',
472
512
  'sleep 1',
473
513
  '',
474
514
  );
@@ -487,10 +527,19 @@ export function buildUnixUpgradeScript({
487
527
  ? npmArgs.replace(/^npm /, '"$NPM" ')
488
528
  : `cd "$INSTALL_DIR" && ${npmArgs.replace(/^npm /, '"$NPM" ')}`;
489
529
 
530
+ // Recheck after acquiring the shared lock. An older queued process must not
531
+ // replace a package that another instance has already upgraded.
532
+ const installedManifest = join(installDir, 'node_modules', pkgName, 'package.json');
533
+ const versionCheck = `try { const fs = require('node:fs'); const v = JSON.parse(fs.readFileSync(process.argv[1])).version; const a = v.split('.').map(Number); const b = process.argv[2].split('.').map(Number); let d = 0; for (let i = 0; i < 3 && !d; i++) d = a[i] - b[i]; process.exit(d >= 0 ? 0 : 1); } catch { process.exit(1); }`;
490
534
  shLines.push(
535
+ `if ${shellQuote(nodePath)} -e ${shellQuote(versionCheck)} ${shellQuote(installedManifest)} ${shellQuote(targetVersion)}; then`,
536
+ ' echo "[Upgrade] Shared installation already has this version or newer; restarting selected instance only"',
537
+ ' EXIT_CODE=0',
538
+ 'else',
491
539
  'echo "[Upgrade] Installing $PKG..."',
492
540
  npmCmd,
493
541
  'EXIT_CODE=$?',
542
+ 'fi',
494
543
  'if [ $EXIT_CODE -ne 0 ]; then',
495
544
  ' echo "[Upgrade] npm install failed with exit code $EXIT_CODE"',
496
545
  'else',
@@ -527,8 +576,8 @@ export function buildUnixUpgradeScript({
527
576
  );
528
577
  }
529
578
 
530
- // 清理脚本自身
531
- shLines.push('', `rm -f "${shPath}"`);
579
+ // Keep failed handoffs for diagnosis; successful runs remove only their own files.
580
+ shLines.push('', run ? 'rm -rf -- "$RUN_DIR"' : `rm -f ${shellQuote(join(configDir, 'upgrade.sh'))}`);
532
581
 
533
582
  return shLines.join('\n');
534
583
  }
@@ -538,7 +587,16 @@ async function spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, late
538
587
  // Create logs/ too: the generated script's `exec > "$LOGFILE" 2>&1` aborts
539
588
  // the whole upgrade silently if that directory is missing.
540
589
  mkdirSync(join(configDir, 'logs'), { recursive: true });
541
- const shPath = join(configDir, 'upgrade.sh');
590
+ const run = createUnixUpgradeRun(configDir);
591
+ const isSystemd = platform() === 'linux' && (
592
+ !!process.env.INVOCATION_ID
593
+ || existsSync(join(homedir(), '.config', 'systemd', 'user', `${getServiceName(instanceId)}.service`))
594
+ );
595
+ // Linux flock is kernel-owned: it releases automatically even when an updater
596
+ // crashes. Never unlink the shared lock inode while another runner may use it.
597
+ const lockPath = platform() === 'linux'
598
+ ? getUnixUpgradeLockPath(installDir, join(getConfigDir(DEFAULT_INSTANCE_ID), 'upgrade-locks'))
599
+ : null;
542
600
 
543
601
  const script = buildUnixUpgradeScript({
544
602
  pkgName,
@@ -550,14 +608,20 @@ async function spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, late
550
608
  npmPath,
551
609
  safePath,
552
610
  instanceId,
611
+ run,
612
+ lockPath,
613
+ isSystemd,
553
614
  });
554
615
 
555
- writeFileSync(shPath, script, { mode: 0o755 });
556
- const child = spawn('bash', [shPath], {
557
- detached: true,
558
- stdio: 'ignore',
616
+ writeFileSync(run.scriptPath, script, { mode: 0o700 });
617
+ await launchUnixUpgrade({
618
+ run,
619
+ instanceId,
620
+ isSystemd,
621
+ env: safeEnv,
622
+ serviceRunnerPath: fileURLToPath(new URL('../yeaft/systemd-service-runner.js', import.meta.url)),
623
+ sendUpgradeAck,
624
+ version: latestVersion,
559
625
  });
560
- child.unref();
561
- console.log(`[Agent] Spawned upgrade script: ${shPath}`);
562
- await sendUpgradeAck({ success: true, version: latestVersion, pendingRestart: true });
626
+ console.log(`[Agent] Upgrade runner ready outside service cgroup: ${run.scriptPath}`);
563
627
  }
@@ -1 +1 @@
1
- {"version":"1.0.674"}
1
+ {"version":"1.0.675"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.674",
3
+ "version": "1.0.675",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,135 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFile, spawn } from 'node:child_process';
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import { promisify } from 'node:util';
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ export function shellQuote(value) {
11
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
12
+ }
13
+
14
+ /** A shared install has one kernel lock, independent of the requesting instance. */
15
+ export function getUnixUpgradeLockPath(installDir, lockRoot) {
16
+ const key = createHash('sha256').update(realpathSync(installDir)).digest('hex');
17
+ mkdirSync(lockRoot, { recursive: true, mode: 0o700 });
18
+ return join(lockRoot, `${key}.lock`);
19
+ }
20
+
21
+ /** Runtime files live outside the npm package and never contain public credentials. */
22
+ export function createUnixUpgradeRun(configDir) {
23
+ const root = join(configDir, 'upgrade-runtime');
24
+ mkdirSync(root, { recursive: true, mode: 0o700 });
25
+ const dir = mkdtempSync(join(root, 'unix-'));
26
+ return {
27
+ dir,
28
+ scriptPath: join(dir, 'upgrade.sh'),
29
+ readyPath: join(dir, 'ready'),
30
+ authorizePath: join(dir, 'authorized'),
31
+ acceptedPath: join(dir, 'accepted'),
32
+ cancelPath: join(dir, 'cancelled'),
33
+ errorPath: join(dir, 'error'),
34
+ };
35
+ }
36
+
37
+ function processRunning(pid) {
38
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
39
+ }
40
+
41
+ /**
42
+ * Start an updater in a separate systemd service (never a detached child in the
43
+ * Agent cgroup). The shell must acquire its install lock and signal readiness
44
+ * before the caller acknowledges or exits. Failure leaves the Agent online.
45
+ */
46
+ export async function launchUnixUpgrade({
47
+ run,
48
+ instanceId,
49
+ isSystemd,
50
+ env = process.env,
51
+ nodePath = process.execPath,
52
+ serviceRunnerPath,
53
+ sendUpgradeAck,
54
+ version,
55
+ timeoutMs = 10_000,
56
+ }, dependencies = {}) {
57
+ const exec = dependencies.execFileAsync || execFileAsync;
58
+ const spawnProcess = dependencies.spawn || spawn;
59
+ const running = dependencies.processRunning || processRunning;
60
+ const unit = `yeaft-upgrade-${instanceId.slice(0, 60)}-${process.pid}-${run.dir.split('unix-').at(-1)}.service`;
61
+ const uid = process.getuid?.();
62
+ const managerEnv = {
63
+ ...env,
64
+ XDG_RUNTIME_DIR: env.XDG_RUNTIME_DIR || `/run/user/${uid}`,
65
+ DBUS_SESSION_BUS_ADDRESS: env.DBUS_SESSION_BUS_ADDRESS || `unix:path=/run/user/${uid}/bus`,
66
+ };
67
+ let child;
68
+ try {
69
+ if (isSystemd) {
70
+ // Pass the inherited environment privately, not as --setenv command-line
71
+ // arguments. The runner consumes/removes this payload before npm runs.
72
+ const payloadDir = join(run.dir, 'invocation');
73
+ mkdirSync(payloadDir, { mode: 0o700 });
74
+ const payloadPath = join(payloadDir, 'invocation.json');
75
+ writeFileSync(payloadPath, JSON.stringify({
76
+ command: '/bin/bash', args: [run.scriptPath], cwd: run.dir, env: managerEnv,
77
+ }), { mode: 0o600 });
78
+ await exec('systemd-run', [
79
+ '--user', '--quiet', '--collect', '--service-type=exec',
80
+ `--unit=${unit}`, '--', nodePath, serviceRunnerPath, payloadPath,
81
+ ], { env: managerEnv, timeout: timeoutMs });
82
+ } else {
83
+ child = spawnProcess('bash', [run.scriptPath], {
84
+ cwd: run.dir, env, detached: true, stdio: 'ignore',
85
+ });
86
+ await new Promise((resolve, reject) => {
87
+ child.once('spawn', resolve);
88
+ child.once('error', reject);
89
+ });
90
+ child.unref();
91
+ }
92
+
93
+ const deadline = Date.now() + timeoutMs;
94
+ let ownerPid;
95
+ while (Date.now() < deadline) {
96
+ if (existsSync(run.errorPath)) throw new Error(readFileSync(run.errorPath, 'utf8').trim());
97
+ try {
98
+ const pid = Number(readFileSync(run.readyPath, 'utf8'));
99
+ if (Number.isInteger(pid) && pid > 0 && running(pid)) { ownerPid = pid; break; }
100
+ } catch {}
101
+ if (child && child.exitCode != null) throw new Error(`Unix upgrade runner exited with code ${child.exitCode}`);
102
+ await delay(50);
103
+ }
104
+ if (!ownerPid) throw new Error('Unix upgrade runner did not become ready; Agent remains online');
105
+ let ackTimer;
106
+ try {
107
+ const outcome = await Promise.race([
108
+ sendUpgradeAck({ success: true, version, pendingRestart: true }),
109
+ new Promise((_, reject) => {
110
+ ackTimer = setTimeout(() => reject(new Error('Upgrade acknowledgement timed out; Agent remains online')), timeoutMs);
111
+ }),
112
+ ]);
113
+ if (outcome !== 'sent') throw new Error('Upgrade acknowledgement was not sent; Agent remains online');
114
+ } finally { clearTimeout(ackTimer); }
115
+ if (!running(ownerPid)) throw new Error('Unix upgrade runner exited during acknowledgement; Agent remains online');
116
+ writeFileSync(run.authorizePath, 'authorized');
117
+ const acceptDeadline = Date.now() + timeoutMs;
118
+ while (Date.now() < acceptDeadline) {
119
+ if (!running(ownerPid)) throw new Error('Unix upgrade runner exited before accepting handoff; Agent remains online');
120
+ if (existsSync(run.acceptedPath)) return { unit: isSystemd ? unit : null };
121
+ await delay(50);
122
+ }
123
+ throw new Error('Unix upgrade runner did not accept handoff; Agent remains online');
124
+ } catch (error) {
125
+ // Revoke authorization even if the manager is slow or start timed out.
126
+ writeFileSync(run.cancelPath, 'cancelled');
127
+ if (isSystemd) {
128
+ try { await exec('systemctl', ['--user', 'stop', unit], { env: managerEnv, timeout: 5000 }); } catch {}
129
+ } else if (child?.pid) {
130
+ try { process.kill(-child.pid, 'SIGTERM'); } catch {}
131
+ }
132
+ // Keep cancellation files: an ambiguously started runner must see them.
133
+ throw error;
134
+ }
135
+ }