@openagents-org/agent-launcher 0.2.190 → 0.2.191
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/package.json +1 -1
- package/src/cli.js +11 -1
- package/src/daemon.js +18 -3
- package/src/node-config.js +55 -18
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -582,7 +582,17 @@ async function cmdNode(connector, flags, positional) {
|
|
|
582
582
|
process.exitCode = 1;
|
|
583
583
|
return;
|
|
584
584
|
}
|
|
585
|
-
|
|
585
|
+
let info;
|
|
586
|
+
try {
|
|
587
|
+
info = nodeCfg.gatherDeviceInfo();
|
|
588
|
+
} catch (e) {
|
|
589
|
+
// Pairing on top of an unreadable node.json would replace the pairings
|
|
590
|
+
// it holds; stop instead, so nothing is lost while it is sorted out.
|
|
591
|
+
print(`Could not read ${nodeCfg.NODE_FILE}: ${e.message}`);
|
|
592
|
+
print('Close anything that may have it open and try again.');
|
|
593
|
+
process.exitCode = 1;
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
586
596
|
if (flags.type) info.deviceType = flags.type; // server | laptop | desktop
|
|
587
597
|
if (flags.name) info.name = flags.name;
|
|
588
598
|
|
package/src/daemon.js
CHANGED
|
@@ -86,6 +86,15 @@ function _sentKey(args) {
|
|
|
86
86
|
return present && !args.useDeviceCredentials;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Whether a node heartbeat failure is the workspace saying the node is gone:
|
|
91
|
+
* a 404 carrying the backend's own message (routers/nodes.py), not any 404.
|
|
92
|
+
*/
|
|
93
|
+
const NODE_GONE_MESSAGES = new Set(['Node not found', 'Workspace not found']);
|
|
94
|
+
function _nodeGone(err) {
|
|
95
|
+
return !!err && err.status === 404 && NODE_GONE_MESSAGES.has(err.message);
|
|
96
|
+
}
|
|
97
|
+
|
|
89
98
|
class Daemon {
|
|
90
99
|
constructor(config, envManager, registry) {
|
|
91
100
|
this.config = config;
|
|
@@ -206,7 +215,9 @@ class Daemon {
|
|
|
206
215
|
const nodeCfg = require('./node-config');
|
|
207
216
|
const pairings = nodeCfg.listPairings().filter((p) => p && p.node_id && p.token);
|
|
208
217
|
if (!pairings.length) return; // no node connected
|
|
209
|
-
|
|
218
|
+
// deviceInfo, not gatherDeviceInfo: the heartbeat sends no device key, and
|
|
219
|
+
// must never be the thing that writes node.json.
|
|
220
|
+
const info = { ...nodeCfg.deviceInfo(), runtimes: this._runtimes || [], fs: this._buildFs() };
|
|
210
221
|
await Promise.all(pairings.map((p) => this._nodeHeartbeatOne(nodeCfg, p, info)));
|
|
211
222
|
}
|
|
212
223
|
|
|
@@ -264,8 +275,12 @@ class Daemon {
|
|
|
264
275
|
// Without this the launcher kept showing "connected" long after a remote
|
|
265
276
|
// removal, since node.json was only ever reconciled by the UI on demand.
|
|
266
277
|
// Transient failures (timeouts, 5xx, auth blips) say nothing about the
|
|
267
|
-
// row's existence, so those are swallowed and retried next tick.
|
|
268
|
-
|
|
278
|
+
// row's existence, so those are swallowed and retried next tick. So is a
|
|
279
|
+
// 404 the backend did not write: the hosting platform answers every
|
|
280
|
+
// request with its own 404 ("Application not found") while the service
|
|
281
|
+
// is unreachable, and taking that as the workspace's word unpaired every
|
|
282
|
+
// workspace on the device at once.
|
|
283
|
+
if (_nodeGone(err)) {
|
|
269
284
|
try { nodeCfg.clearPairing(n.workspace_id); } catch {}
|
|
270
285
|
this._nodeClients.delete(n.workspace_id);
|
|
271
286
|
this._log(`node ${n.node_id} no longer recognized by workspace ${n.workspace_slug || n.workspace_id} — pairing cleared`);
|
package/src/node-config.js
CHANGED
|
@@ -16,30 +16,64 @@ const crypto = require('crypto');
|
|
|
16
16
|
const NODE_DIR = path.join(os.homedir(), '.openagents');
|
|
17
17
|
const NODE_FILE = path.join(NODE_DIR, 'node.json');
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* The record on disk: null when there is no file, and a throw when there is
|
|
21
|
+
* one that cannot be read or parsed (half-written by another process, or held
|
|
22
|
+
* by an antivirus scan on Windows). Every writer reads through this, so an
|
|
23
|
+
* unreadable file is left alone rather than rebuilt from nothing — which
|
|
24
|
+
* dropped every pairing and minted a new device key.
|
|
25
|
+
*/
|
|
26
|
+
function readNode() {
|
|
27
|
+
let raw;
|
|
20
28
|
try {
|
|
21
|
-
|
|
22
|
-
} catch {
|
|
23
|
-
|
|
29
|
+
raw = fs.readFileSync(NODE_FILE, 'utf-8');
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if (err && err.code === 'ENOENT') return null;
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
return JSON.parse(raw);
|
|
24
35
|
}
|
|
25
36
|
|
|
37
|
+
/** Lenient read for display and heartbeat: an unreadable file counts as none. */
|
|
38
|
+
function loadNode() {
|
|
39
|
+
try { return readNode(); } catch { return null; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Written through a temp file and a rename, so a reader in the other process
|
|
44
|
+
* (launcher or daemon) sees the old record or the new one, never a torn one.
|
|
45
|
+
*/
|
|
26
46
|
function saveNode(data) {
|
|
27
47
|
try {
|
|
28
48
|
fs.mkdirSync(NODE_DIR, { recursive: true });
|
|
29
|
-
|
|
30
|
-
|
|
49
|
+
const tmp = `${NODE_FILE}.${process.pid}.tmp`;
|
|
50
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 }); // holds the workspace token
|
|
51
|
+
try {
|
|
52
|
+
fs.renameSync(tmp, NODE_FILE);
|
|
53
|
+
} catch {
|
|
54
|
+
// Windows refuses the rename while another process holds the target.
|
|
55
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
56
|
+
fs.writeFileSync(NODE_FILE, JSON.stringify(data, null, 2));
|
|
57
|
+
}
|
|
58
|
+
try { fs.chmodSync(NODE_FILE, 0o600); } catch {}
|
|
31
59
|
} catch {}
|
|
32
60
|
}
|
|
33
61
|
|
|
34
|
-
/** Stable per-device id, generated once and persisted. */
|
|
62
|
+
/** Stable per-device id, generated once and persisted. Throws if node.json is unreadable. */
|
|
35
63
|
function getOrCreateNodeKey() {
|
|
36
|
-
const existing =
|
|
64
|
+
const existing = readNode();
|
|
37
65
|
if (existing && existing.node_key) return existing.node_key;
|
|
38
66
|
const key = (crypto.randomUUID && crypto.randomUUID()) || crypto.randomBytes(16).toString('hex');
|
|
39
67
|
saveNode({ ...(existing || {}), node_key: key });
|
|
40
68
|
return key;
|
|
41
69
|
}
|
|
42
70
|
|
|
71
|
+
function pairingsOf(record) {
|
|
72
|
+
if (!record) return [];
|
|
73
|
+
if (record.pairings && record.pairings.length) return record.pairings;
|
|
74
|
+
return record.workspace_id ? [record] : [];
|
|
75
|
+
}
|
|
76
|
+
|
|
43
77
|
/**
|
|
44
78
|
* Every workspace this device is paired to, most recent first.
|
|
45
79
|
*
|
|
@@ -49,10 +83,7 @@ function getOrCreateNodeKey() {
|
|
|
49
83
|
* existed carry a single top-level pairing, which is reconstructed here.
|
|
50
84
|
*/
|
|
51
85
|
function listPairings() {
|
|
52
|
-
|
|
53
|
-
if (!record) return [];
|
|
54
|
-
if (record.pairings && record.pairings.length) return record.pairings;
|
|
55
|
-
return record.workspace_id ? [record] : [];
|
|
86
|
+
return pairingsOf(loadNode());
|
|
56
87
|
}
|
|
57
88
|
|
|
58
89
|
/**
|
|
@@ -67,7 +98,7 @@ function recordPairing(nodeKey, pairing) {
|
|
|
67
98
|
const stamped = { ...pairing, paired_at: new Date().toISOString() };
|
|
68
99
|
const pairings = [
|
|
69
100
|
stamped,
|
|
70
|
-
...
|
|
101
|
+
...pairingsOf(readNode()).filter((p) => p.workspace_id !== pairing.workspace_id),
|
|
71
102
|
];
|
|
72
103
|
saveNode({ node_key: nodeKey, ...stamped, pairings });
|
|
73
104
|
}
|
|
@@ -86,12 +117,12 @@ function recordPairing(nodeKey, pairing) {
|
|
|
86
117
|
* @returns the pairing that was dropped, or null if there was no such pairing.
|
|
87
118
|
*/
|
|
88
119
|
function clearPairing(workspaceId) {
|
|
89
|
-
const record =
|
|
120
|
+
const record = readNode();
|
|
90
121
|
if (!record) return null;
|
|
91
122
|
const target = workspaceId || record.workspace_id;
|
|
92
123
|
if (!target) return null;
|
|
93
124
|
|
|
94
|
-
const pairings =
|
|
125
|
+
const pairings = pairingsOf(record);
|
|
95
126
|
const dropped = pairings.find((p) => p.workspace_id === target);
|
|
96
127
|
if (!dropped) return null;
|
|
97
128
|
|
|
@@ -114,12 +145,11 @@ function inferDeviceType() {
|
|
|
114
145
|
return 'server'; // linux is typically a server/VM in this context
|
|
115
146
|
}
|
|
116
147
|
|
|
117
|
-
/** Device info sent on
|
|
118
|
-
function
|
|
148
|
+
/** Device info sent on every heartbeat. Reads and writes nothing on disk. */
|
|
149
|
+
function deviceInfo() {
|
|
119
150
|
let launcherVersion = '';
|
|
120
151
|
try { launcherVersion = require('../package.json').version; } catch {}
|
|
121
152
|
return {
|
|
122
|
-
nodeKey: getOrCreateNodeKey(),
|
|
123
153
|
hostname: os.hostname(),
|
|
124
154
|
os: os.platform(),
|
|
125
155
|
deviceType: inferDeviceType(),
|
|
@@ -127,7 +157,13 @@ function gatherDeviceInfo() {
|
|
|
127
157
|
};
|
|
128
158
|
}
|
|
129
159
|
|
|
160
|
+
/** Device info sent on redeem, with the device key (minted on first pairing). */
|
|
161
|
+
function gatherDeviceInfo() {
|
|
162
|
+
return { nodeKey: getOrCreateNodeKey(), ...deviceInfo() };
|
|
163
|
+
}
|
|
164
|
+
|
|
130
165
|
module.exports = {
|
|
166
|
+
readNode,
|
|
131
167
|
loadNode,
|
|
132
168
|
saveNode,
|
|
133
169
|
listPairings,
|
|
@@ -135,6 +171,7 @@ module.exports = {
|
|
|
135
171
|
clearPairing,
|
|
136
172
|
getOrCreateNodeKey,
|
|
137
173
|
inferDeviceType,
|
|
174
|
+
deviceInfo,
|
|
138
175
|
gatherDeviceInfo,
|
|
139
176
|
NODE_FILE,
|
|
140
177
|
NODE_DIR,
|