@yeaft/webchat-agent 1.0.352 → 1.0.353

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.
@@ -16,7 +16,7 @@ export const pendingProxyRequests = new Map(); // requestId → { res, timeout,
16
16
  export const proxyWsConnections = new Map(); // proxyWsId → { browserWs, agentId }
17
17
 
18
18
  // Store pending agent connections (waiting for auth message)
19
- // tempId -> { ws, agentId, agentName, workDir, timeout }
19
+ // tempId -> { ws, agentId, agentName, instanceId, workDir, skipAgentAuth, connectionGeneration, timeout }
20
20
  export const pendingAgentConnections = new Map();
21
21
 
22
22
  // ★ Phase 3: Server-side message queues
@@ -3,6 +3,25 @@ import {
3
3
  sendToWebClient, forwardToAgent, broadcastAgentList
4
4
  } from '../ws-utils.js';
5
5
 
6
+ // v1.0.342 is the first release with the bootstrap -> detached runner handoff.
7
+ // Older Windows Agents can lose their updater to PM2 tree-kill before npm runs.
8
+ export const MIN_SAFE_REMOTE_UPGRADE_VERSION = '1.0.342';
9
+
10
+ function parseVersion(version) {
11
+ const match = String(version || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)$/u);
12
+ return match ? match.slice(1).map(Number) : null;
13
+ }
14
+
15
+ export function requiresManualUpgradeBridge(version) {
16
+ const current = parseVersion(version);
17
+ const minimum = parseVersion(MIN_SAFE_REMOTE_UPGRADE_VERSION);
18
+ if (!current || !minimum) return true;
19
+ for (let index = 0; index < minimum.length; index++) {
20
+ if (current[index] !== minimum[index]) return current[index] < minimum[index];
21
+ }
22
+ return false;
23
+ }
24
+
6
25
  /**
7
26
  * Handle miscellaneous messages from web client.
8
27
  * Types: ping, restart_agent, upgrade_agent,
@@ -26,6 +45,19 @@ export async function handleClientMisc(clientId, client, msg, checkAgentAccess)
26
45
  const upgradeAgentId = msg.agentId;
27
46
  if (!upgradeAgentId) break;
28
47
  if (!await checkAgentAccess(upgradeAgentId)) break;
48
+ const upgradeAgent = agents.get(upgradeAgentId);
49
+ if (requiresManualUpgradeBridge(upgradeAgent?.version)) {
50
+ await sendToWebClient(client, {
51
+ type: 'upgrade_agent_ack',
52
+ agentId: upgradeAgentId,
53
+ success: false,
54
+ reason: 'manual_upgrade_required',
55
+ version: upgradeAgent?.version || null,
56
+ minimumVersion: MIN_SAFE_REMOTE_UPGRADE_VERSION,
57
+ error: `Agent ${upgradeAgent?.version || 'unknown'} predates the safe remote-upgrade handoff; manually install ${MIN_SAFE_REMOTE_UPGRADE_VERSION} or newer once`,
58
+ });
59
+ break;
60
+ }
29
61
  await forwardToAgent(upgradeAgentId, { type: 'upgrade_agent' });
30
62
  break;
31
63
  }
@@ -25,60 +25,45 @@ function buildAgentMapKey(ownerId, agentName) {
25
25
  return `${prefix}:${agentName}`;
26
26
  }
27
27
 
28
+ let nextAgentConnectionGeneration = 0;
29
+ // Keep the latest generation as a tombstone while an older auth may still arrive.
30
+ const latestAgentConnectionGenerations = new Map();
31
+
32
+ function claimAgentConnection(agentId, generation) {
33
+ const latestGeneration = latestAgentConnectionGenerations.get(agentId);
34
+ if (latestGeneration !== undefined && latestGeneration > generation) return false;
35
+ latestAgentConnectionGenerations.set(agentId, generation);
36
+ return true;
37
+ }
38
+
39
+ function pruneAgentConnectionGenerations() {
40
+ for (const [agentId, generation] of latestAgentConnectionGenerations) {
41
+ if (agents.has(agentId)) continue;
42
+ const hasPotentiallyOlderConnection = [...pendingAgentConnections.values()].some(pending => {
43
+ if (pending.connectionGeneration >= generation) return false;
44
+ return pending.skipAgentAuth ? pending.agentId === agentId : true;
45
+ });
46
+ if (!hasPotentiallyOlderConnection) latestAgentConnectionGenerations.delete(agentId);
47
+ }
48
+ }
49
+
28
50
  export function handleAgentConnection(ws, url) {
29
51
  const clientAgentId = url.searchParams.get('id') || randomUUID();
30
52
  const agentName = url.searchParams.get('name') || `Agent-${clientAgentId.slice(0, 8)}`;
31
53
  const instanceId = url.searchParams.get('instanceId') || clientAgentId;
32
54
  const workDir = url.searchParams.get('workDir') || '';
33
55
 
34
- // In development mode (SKIP_AUTH), register immediately
35
- if (CONFIG.skipAuth) {
36
- // Dev mode: no owner isolation, use clientAgentId as-is for backward compat
37
- const capabilities = (url.searchParams.get('capabilities') || '').split(',').filter(Boolean);
38
- completeAgentRegistration(ws, clientAgentId, agentName, workDir, null, capabilities, null, null, null, instanceId);
56
+ // Both authenticated and SKIP_AUTH connections use the existing auth frame
57
+ // for registration metadata. Released Agents already send their version there;
58
+ // SKIP_AUTH only bypasses secret validation and owner scoping.
59
+ const skipAgentAuth = CONFIG.skipAuth;
60
+ const urlCapabilities = (url.searchParams.get('capabilities') || '').split(',').filter(Boolean);
61
+ const connectionGeneration = ++nextAgentConnectionGeneration;
39
62
 
40
- ws.on('message', async (data) => {
41
- const agent = agents.get(clientAgentId);
42
- if (!agent || agent.ws !== ws) {
43
- if (!agent) console.error(`[Agent] No agent found for id: ${clientAgentId}`);
44
- return;
45
- }
46
- markAgentHeartbeatSeen(agent);
47
- const msg = await parseMessage(data, agent.sessionKey);
48
- if (msg) {
49
- console.log(`[Agent] Received message from ${clientAgentId}: ${msg.type}`);
50
- if (msg.perfTraceId) {
51
- recordPerfTraceEvent({
52
- traceId: msg.perfTraceId,
53
- source: 'server',
54
- phase: 'websocket.agent_received',
55
- at: Date.now(),
56
- agentId: clientAgentId,
57
- sessionId: msg.sessionId || null,
58
- vpId: msg.vpId || null,
59
- turnId: msg.turnId || null,
60
- threadId: msg.threadId || null,
61
- messageType: msg.type,
62
- bytes: data.length || 0,
63
- });
64
- }
65
- handleAgentMessage(clientAgentId, msg, ws);
66
- } else {
67
- console.error(`[Agent] Failed to parse message from ${clientAgentId}`);
68
- }
69
- });
63
+ // SKIP_AUTH has a complete identity at connect time. Authenticated connections
64
+ // can only claim an owner-scoped identity after their secret is verified.
65
+ if (skipAgentAuth) claimAgentConnection(clientAgentId, connectionGeneration);
70
66
 
71
- ws.on('close', () => {
72
- handleAgentDisconnect(clientAgentId, agentName, ws);
73
- });
74
-
75
- ws.on('error', (err) => {
76
- console.error(`Agent error (${agentName}):`, err.message);
77
- });
78
- return;
79
- }
80
-
81
- // In production mode, wait for auth message with secret
82
67
  const tempId = randomUUID();
83
68
  // Mutable: will be updated to the owner-scoped key after auth succeeds
84
69
  let resolvedAgentId = null;
@@ -86,6 +71,7 @@ export function handleAgentConnection(ws, url) {
86
71
  const authTimeout = setTimeout(() => {
87
72
  console.log(`Agent auth timeout: ${agentName}`);
88
73
  pendingAgentConnections.delete(tempId);
74
+ pruneAgentConnectionGenerations();
89
75
  ws.close(1008, 'Authentication timeout');
90
76
  }, 30000);
91
77
 
@@ -95,10 +81,12 @@ export function handleAgentConnection(ws, url) {
95
81
  agentName,
96
82
  instanceId,
97
83
  workDir,
84
+ skipAgentAuth,
85
+ connectionGeneration,
98
86
  timeout: authTimeout
99
87
  });
100
88
 
101
- // Request authentication
89
+ // Request the existing registration frame. SKIP_AUTH ignores its secret.
102
90
  ws.send(JSON.stringify({
103
91
  type: 'auth_required',
104
92
  tempId
@@ -114,22 +102,34 @@ export function handleAgentConnection(ws, url) {
114
102
  clearTimeout(pending.timeout);
115
103
  pendingAgentConnections.delete(tempId);
116
104
 
117
- const authResult = verifyAgent(msg.secret);
105
+ const authResult = skipAgentAuth
106
+ ? { valid: true, sessionKey: null, userId: null, username: null }
107
+ : verifyAgent(msg.secret);
118
108
  if (!authResult.valid) {
109
+ pruneAgentConnectionGenerations();
119
110
  console.log(`Agent auth failed: ${agentName}`);
120
111
  ws.close(1008, 'Invalid agent secret');
121
112
  return;
122
113
  }
123
114
 
124
- const capabilities = msg.capabilities || [];
115
+ const capabilities = Array.isArray(msg.capabilities) ? msg.capabilities : urlCapabilities;
125
116
  const agentVersion = msg.version || null;
126
- // Build owner-scoped key to prevent cross-user collision. New agents
127
- // identify local service instances separately from display names;
128
- // old agents keep using their name as the stable id.
129
- resolvedAgentId = buildAgentMapKey(authResult.userId, pending.instanceId || pending.agentId || pending.agentName);
117
+ // Authenticated Agents use an owner-scoped key. SKIP_AUTH preserves
118
+ // its historical unscoped id while still receiving version metadata.
119
+ resolvedAgentId = skipAgentAuth
120
+ ? clientAgentId
121
+ : buildAgentMapKey(authResult.userId, pending.instanceId || pending.agentId || pending.agentName);
122
+ if (!claimAgentConnection(resolvedAgentId, connectionGeneration)) {
123
+ resolvedAgentId = null;
124
+ pruneAgentConnectionGenerations();
125
+ ws.close(1008, 'Superseded by a newer Agent connection');
126
+ return;
127
+ }
130
128
  completeAgentRegistration(ws, resolvedAgentId, pending.agentName, pending.workDir, authResult.sessionKey, capabilities, authResult.userId, authResult.username, agentVersion, pending.instanceId || pending.agentId || pending.agentName);
129
+ pruneAgentConnectionGenerations();
131
130
  }
132
131
  } catch (e) {
132
+ pruneAgentConnectionGenerations();
133
133
  console.error('Failed to parse agent auth message:', e.message);
134
134
  }
135
135
  } else {
@@ -171,6 +171,7 @@ export function handleAgentConnection(ws, url) {
171
171
  if (pending) {
172
172
  clearTimeout(pending.timeout);
173
173
  pendingAgentConnections.delete(tempId);
174
+ pruneAgentConnectionGenerations();
174
175
  }
175
176
  // Use resolvedAgentId if auth completed, otherwise nothing to clean
176
177
  if (resolvedAgentId) {
@@ -199,6 +200,7 @@ function handleAgentDisconnect(agentId, agentName, ws) {
199
200
  }
200
201
  // Remove agent entirely — eliminates zombie agents from broadcastAgentList
201
202
  agents.delete(agentId);
203
+ pruneAgentConnectionGenerations();
202
204
  console.log(`Agent disconnected: ${agentName}`);
203
205
  broadcastAgentList();
204
206
  }
@@ -1 +1 @@
1
- {"version":"1.0.352"}
1
+ {"version":"1.0.353"}