@openagents-org/agent-launcher 0.2.165 → 0.2.166
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 -7
- package/src/daemon.js +90 -26
- package/src/node-config.js +69 -0
- package/src/workspace-client.js +4 -0
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -440,11 +440,13 @@ async function cmdNode(connector, flags, positional) {
|
|
|
440
440
|
print('Connecting this device to your workspace...');
|
|
441
441
|
try {
|
|
442
442
|
const res = await connector.redeemNodePairingCode(code, info);
|
|
443
|
-
|
|
443
|
+
// recordPairing, not saveNode: this device can be a node in several
|
|
444
|
+
// workspaces at once, and a bare save would drop the others' pairings.
|
|
445
|
+
nodeCfg.recordPairing(info.nodeKey, {
|
|
444
446
|
node_id: res.nodeId,
|
|
445
|
-
node_key: info.nodeKey,
|
|
446
447
|
workspace_id: res.workspaceId,
|
|
447
448
|
workspace_slug: res.workspaceSlug,
|
|
449
|
+
workspace_name: res.workspaceName,
|
|
448
450
|
endpoint: connector.workspace.endpoint,
|
|
449
451
|
token: res.token,
|
|
450
452
|
});
|
|
@@ -484,12 +486,14 @@ async function cmdNode(connector, flags, positional) {
|
|
|
484
486
|
}
|
|
485
487
|
|
|
486
488
|
if (sub === 'status') {
|
|
487
|
-
const
|
|
488
|
-
if (
|
|
489
|
-
print(`Node connected: ${n.node_id}`);
|
|
490
|
-
print(` Workspace: ${n.workspace_slug || n.workspace_id}`);
|
|
491
|
-
} else {
|
|
489
|
+
const pairings = nodeCfg.listPairings().filter((p) => p.node_id);
|
|
490
|
+
if (!pairings.length) {
|
|
492
491
|
print('No node connected. Run: agn node connect <pairing-code>');
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
print(`Node connected to ${pairings.length} workspace(s):`);
|
|
495
|
+
for (const p of pairings) {
|
|
496
|
+
print(` ${p.workspace_slug || p.workspace_id} — node ${p.node_id}`);
|
|
493
497
|
}
|
|
494
498
|
return;
|
|
495
499
|
}
|
package/src/daemon.js
CHANGED
|
@@ -31,7 +31,7 @@ class Daemon {
|
|
|
31
31
|
this._statusInterval = null;
|
|
32
32
|
this._cmdInterval = null;
|
|
33
33
|
this._nodeHeartbeatInterval = null; // device-level heartbeat (connect-a-node)
|
|
34
|
-
this.
|
|
34
|
+
this._nodeClients = new Map(); // workspace_id -> WorkspaceClient, one per pairing
|
|
35
35
|
this._runningCommands = new Set(); // node-command ids currently executing
|
|
36
36
|
this._runtimes = []; // detected agent runtimes for the node view
|
|
37
37
|
this._runtimesInterval = null;
|
|
@@ -39,34 +39,78 @@ class Daemon {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* Heartbeat this device (node) to
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* Heartbeat this device (node) to EVERY workspace it is paired to,
|
|
43
|
+
* independent of any agent, so each workspace's "connected devices" view
|
|
44
|
+
* stays live even with zero agents there.
|
|
45
|
+
*
|
|
46
|
+
* One device legitimately belongs to several workspaces at once — the node
|
|
47
|
+
* row is per (workspace, device) — so this iterates the pairing list rather
|
|
48
|
+
* than the single top-level record. Each pairing gets its own roster, scoped
|
|
49
|
+
* to that workspace's agents. Best-effort: node liveness is non-critical to
|
|
50
|
+
* agent operation, and one workspace being unreachable must not stop the
|
|
51
|
+
* others from reporting, so the pairings are heartbeated concurrently and
|
|
52
|
+
* failures are contained per pairing.
|
|
45
53
|
*/
|
|
46
54
|
async _nodeHeartbeat() {
|
|
55
|
+
const nodeCfg = require('./node-config');
|
|
56
|
+
const pairings = nodeCfg.listPairings().filter((p) => p && p.node_id && p.token);
|
|
57
|
+
if (!pairings.length) return; // no node connected
|
|
58
|
+
const info = { ...nodeCfg.gatherDeviceInfo(), runtimes: this._runtimes, fs: this._buildFs() };
|
|
59
|
+
await Promise.all(pairings.map((p) => this._nodeHeartbeatOne(nodeCfg, p, info)));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The WorkspaceClient for one pairing, created on first use and reused. */
|
|
63
|
+
_nodeClientFor(n) {
|
|
64
|
+
let client = this._nodeClients.get(n.workspace_id);
|
|
65
|
+
if (!client) {
|
|
66
|
+
client = new WorkspaceClient(n.endpoint);
|
|
67
|
+
this._nodeClients.set(n.workspace_id, client);
|
|
68
|
+
}
|
|
69
|
+
return client;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One pairing's heartbeat. Never throws — see _nodeHeartbeat. */
|
|
73
|
+
async _nodeHeartbeatOne(nodeCfg, n, info) {
|
|
74
|
+
let resp;
|
|
47
75
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
76
|
+
resp = await this._nodeClientFor(n).nodeHeartbeat(n.node_id, n.token, { ...info, agents: this._buildRoster(n) });
|
|
77
|
+
} catch (err) {
|
|
78
|
+
// A 404 is the workspace's definitive word that this node (or that whole
|
|
79
|
+
// workspace) no longer exists — an owner unpaired the device. Nothing
|
|
80
|
+
// local can revive it, so forget THAT pairing: keep the device key for a
|
|
81
|
+
// future re-pair, and leave every other workspace's pairing alone.
|
|
82
|
+
// Without this the launcher kept showing "connected" long after a remote
|
|
83
|
+
// removal, since node.json was only ever reconciled by the UI on demand.
|
|
84
|
+
// Transient failures (timeouts, 5xx, auth blips) say nothing about the
|
|
85
|
+
// row's existence, so those are swallowed and retried next tick.
|
|
86
|
+
if (err && err.status === 404) {
|
|
87
|
+
try { nodeCfg.clearPairing(n.workspace_id); } catch {}
|
|
88
|
+
this._nodeClients.delete(n.workspace_id);
|
|
89
|
+
this._log(`node ${n.node_id} no longer recognized by workspace ${n.workspace_slug || n.workspace_id} — pairing cleared`);
|
|
53
90
|
}
|
|
54
|
-
|
|
55
|
-
const resp = await this._nodeClient.nodeHeartbeat(n.node_id, n.token, info);
|
|
56
|
-
// The heartbeat response is our push channel: run any queued remote
|
|
57
|
-
// agent-management commands the workspace enqueued for this node. Fire
|
|
58
|
-
// them off without blocking the heartbeat loop (an install can take
|
|
59
|
-
// minutes — awaiting here would stall liveness and stack up commands).
|
|
60
|
-
const commands = (resp && resp.commands) || [];
|
|
61
|
-
for (const cmd of commands) {
|
|
62
|
-
const id = cmd.commandId;
|
|
63
|
-
if (!id || this._runningCommands.has(id)) continue;
|
|
64
|
-
this._runningCommands.add(id);
|
|
65
|
-
this._runNodeCommand(n, cmd).finally(() => this._runningCommands.delete(id));
|
|
66
|
-
}
|
|
67
|
-
} catch {
|
|
68
|
-
// ignore — will retry next interval
|
|
91
|
+
return; // nothing more to do for this pairing this tick
|
|
69
92
|
}
|
|
93
|
+
// The heartbeat response is our push channel: run any queued remote
|
|
94
|
+
// agent-management commands the workspace enqueued for this node. Fire
|
|
95
|
+
// them off without blocking the heartbeat loop (an install can take
|
|
96
|
+
// minutes — awaiting here would stall liveness and stack up commands).
|
|
97
|
+
const commands = (resp && resp.commands) || [];
|
|
98
|
+
for (const cmd of commands) {
|
|
99
|
+
const id = cmd.commandId;
|
|
100
|
+
if (!id || this._runningCommands.has(id)) continue;
|
|
101
|
+
this._runningCommands.add(id);
|
|
102
|
+
this._runNodeCommand(n, cmd).finally(() => this._runningCommands.delete(id));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* True if agent `a` is bound to the node's currently-connected workspace.
|
|
108
|
+
* A missing network never matches (guards against undefined === undefined
|
|
109
|
+
* leaking local-only agents when a node field is also unset).
|
|
110
|
+
*/
|
|
111
|
+
_agentOnNodeWorkspace(a, node) {
|
|
112
|
+
if (!a || !a.network || !node) return false;
|
|
113
|
+
return a.network === node.workspace_slug || a.network === node.workspace_id;
|
|
70
114
|
}
|
|
71
115
|
|
|
72
116
|
/**
|
|
@@ -74,11 +118,18 @@ class Daemon {
|
|
|
74
118
|
* from config (the source of truth for what's configured) and augmented with
|
|
75
119
|
* live process state, so a removed agent drops off immediately rather than
|
|
76
120
|
* lingering as a stale 'stopped' process entry.
|
|
121
|
+
*
|
|
122
|
+
* SECURITY: scoped to the node's currently-connected workspace. An agent
|
|
123
|
+
* connected to a DIFFERENT workspace (or a local-only agent with no network)
|
|
124
|
+
* must never leak into — or be controllable from — a workspace it was never
|
|
125
|
+
* added to. Each agent belongs to exactly one workspace; this node reports
|
|
126
|
+
* only the agents that belong to the one it's paired with right now.
|
|
77
127
|
*/
|
|
78
|
-
_buildRoster() {
|
|
128
|
+
_buildRoster(node) {
|
|
79
129
|
const roster = [];
|
|
80
130
|
try {
|
|
81
131
|
for (const a of this.config.getAgents()) {
|
|
132
|
+
if (!this._agentOnNodeWorkspace(a, node)) continue;
|
|
82
133
|
const proc = this._processes[a.name];
|
|
83
134
|
// Model: per-agent env override, else the type-level saved model. Working
|
|
84
135
|
// dir: the agent's configured path. Both power the workspace agent cards.
|
|
@@ -172,7 +223,18 @@ class Daemon {
|
|
|
172
223
|
let ok = false;
|
|
173
224
|
let message = '';
|
|
174
225
|
let data = null;
|
|
226
|
+
// SECURITY: a workspace may only act on agents bound to it. Reject commands
|
|
227
|
+
// that target an existing agent connected to a different workspace, so a
|
|
228
|
+
// newly-paired workspace can't start/stop/remove/reconfigure agents that
|
|
229
|
+
// belong to another one (they surface in no roster of ours either).
|
|
230
|
+
const AGENT_SCOPED = new Set(['start_agent', 'stop_agent', 'remove_agent', 'configure_agent']);
|
|
175
231
|
try {
|
|
232
|
+
if (AGENT_SCOPED.has(action)) {
|
|
233
|
+
const existing = this.config.getAgent(name);
|
|
234
|
+
if (!existing || !this._agentOnNodeWorkspace(existing, n)) {
|
|
235
|
+
throw new Error(`Agent '${name}' is not managed by this workspace`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
176
238
|
if (action === 'create_agent') {
|
|
177
239
|
const type = (args.type || '').trim();
|
|
178
240
|
// Working directory: use the one the user picked, else a managed folder
|
|
@@ -245,7 +307,9 @@ class Daemon {
|
|
|
245
307
|
message = e.message || String(e);
|
|
246
308
|
}
|
|
247
309
|
try {
|
|
248
|
-
|
|
310
|
+
// Report back to the workspace that issued the command — with several
|
|
311
|
+
// pairings live, the result must not go out over another one's client.
|
|
312
|
+
await this._nodeClientFor(n).nodeCommandResult(cmd.commandId, n.token, { ok, message, data });
|
|
249
313
|
} catch {
|
|
250
314
|
// best-effort; the command stays 'running' if we can't report back
|
|
251
315
|
}
|
package/src/node-config.js
CHANGED
|
@@ -40,6 +40,72 @@ function getOrCreateNodeKey() {
|
|
|
40
40
|
return key;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Every workspace this device is paired to, most recent first.
|
|
45
|
+
*
|
|
46
|
+
* All of them are live: the server-side node row is keyed per (workspace,
|
|
47
|
+
* node_key), so one device holds a membership in each workspace it paired with
|
|
48
|
+
* and the daemon heartbeats every one. Records written before `pairings`
|
|
49
|
+
* existed carry a single top-level pairing, which is reconstructed here.
|
|
50
|
+
*/
|
|
51
|
+
function listPairings() {
|
|
52
|
+
const record = loadNode();
|
|
53
|
+
if (!record) return [];
|
|
54
|
+
if (record.pairings && record.pairings.length) return record.pairings;
|
|
55
|
+
return record.workspace_id ? [record] : [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Add a pairing, or refresh it if this workspace is already paired.
|
|
60
|
+
*
|
|
61
|
+
* Additive on purpose: pairing a second workspace must NOT take the device away
|
|
62
|
+
* from the ones it already belongs to (each issues its own node row). The newest
|
|
63
|
+
* pairing is mirrored at the top level, which is what a daemon predating
|
|
64
|
+
* multi-pairing reads. Mirrors the launcher's writer (`node-pairing.ts`).
|
|
65
|
+
*/
|
|
66
|
+
function recordPairing(nodeKey, pairing) {
|
|
67
|
+
const stamped = { ...pairing, paired_at: new Date().toISOString() };
|
|
68
|
+
const pairings = [
|
|
69
|
+
stamped,
|
|
70
|
+
...listPairings().filter((p) => p.workspace_id !== pairing.workspace_id),
|
|
71
|
+
];
|
|
72
|
+
saveNode({ node_key: nodeKey, ...stamped, pairings });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Forget one workspace's pairing — that workspace no longer recognizes this node.
|
|
77
|
+
*
|
|
78
|
+
* Keeps `node_key` (so a re-pair reuses the same device id) and drops just the
|
|
79
|
+
* one entry: the server-side node row for THAT workspace is gone and can only
|
|
80
|
+
* be replaced by a fresh code, while every other pairing is still good. The top
|
|
81
|
+
* level is re-pointed at whichever pairing is now first, since that is what a
|
|
82
|
+
* daemon predating multi-pairing reads. Mirrors the launcher's writer
|
|
83
|
+
* (`node-pairing.ts`) so the two stay schema-compatible.
|
|
84
|
+
*
|
|
85
|
+
* @param workspaceId which pairing to drop; defaults to the top-level one.
|
|
86
|
+
* @returns the pairing that was dropped, or null if there was no such pairing.
|
|
87
|
+
*/
|
|
88
|
+
function clearPairing(workspaceId) {
|
|
89
|
+
const record = loadNode();
|
|
90
|
+
if (!record) return null;
|
|
91
|
+
const target = workspaceId || record.workspace_id;
|
|
92
|
+
if (!target) return null;
|
|
93
|
+
|
|
94
|
+
const pairings = listPairings();
|
|
95
|
+
const dropped = pairings.find((p) => p.workspace_id === target);
|
|
96
|
+
if (!dropped) return null;
|
|
97
|
+
|
|
98
|
+
const remaining = pairings.filter((p) => p.workspace_id !== target);
|
|
99
|
+
saveNode({ node_key: record.node_key, ...remaining[0], pairings: remaining });
|
|
100
|
+
return {
|
|
101
|
+
node_id: dropped.node_id,
|
|
102
|
+
workspace_id: dropped.workspace_id,
|
|
103
|
+
workspace_slug: dropped.workspace_slug,
|
|
104
|
+
workspace_name: dropped.workspace_name,
|
|
105
|
+
endpoint: dropped.endpoint,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
43
109
|
/** Best-effort device type from the platform (user/UI can override). */
|
|
44
110
|
function inferDeviceType() {
|
|
45
111
|
const p = os.platform();
|
|
@@ -64,6 +130,9 @@ function gatherDeviceInfo() {
|
|
|
64
130
|
module.exports = {
|
|
65
131
|
loadNode,
|
|
66
132
|
saveNode,
|
|
133
|
+
listPairings,
|
|
134
|
+
recordPairing,
|
|
135
|
+
clearPairing,
|
|
67
136
|
getOrCreateNodeKey,
|
|
68
137
|
inferDeviceType,
|
|
69
138
|
gatherDeviceInfo,
|
package/src/workspace-client.js
CHANGED
|
@@ -969,6 +969,10 @@ class WorkspaceClient {
|
|
|
969
969
|
// Preserve structured error details (error_code, hint,
|
|
970
970
|
// quota occupancy, ...) so tool handlers can surface them.
|
|
971
971
|
if (parsed.data && typeof parsed.data === 'object') err.data = parsed.data;
|
|
972
|
+
// The HTTP status lets callers tell a definitive rejection
|
|
973
|
+
// (e.g. 404 "not found") from a transient one (5xx/timeout);
|
|
974
|
+
// the node heartbeat uses it to clear a removed pairing.
|
|
975
|
+
err.status = res.statusCode;
|
|
972
976
|
reject(err);
|
|
973
977
|
}
|
|
974
978
|
} else {
|