@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openagents-org/agent-launcher",
3
- "version": "0.2.165",
3
+ "version": "0.2.166",
4
4
  "description": "OpenAgents Launcher \u2014 install, configure, and run AI coding agents from your terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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
- nodeCfg.saveNode({
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 n = nodeCfg.loadNode();
488
- if (n && n.node_id) {
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._nodeClient = null; // lazily-created WorkspaceClient for the node
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 its workspace, independent of any agent, so
43
- * the workspace's "connected devices" view stays live even with zero agents.
44
- * Best-effort: node liveness is non-critical to agent operation.
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
- const nodeCfg = require('./node-config');
49
- const n = nodeCfg.loadNode();
50
- if (!n || !n.node_id || !n.token) return; // no node connected
51
- if (!this._nodeClient) {
52
- this._nodeClient = new WorkspaceClient(n.endpoint);
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
- const info = { ...nodeCfg.gatherDeviceInfo(), agents: this._buildRoster(), runtimes: this._runtimes, fs: this._buildFs() };
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
- await this._nodeClient.nodeCommandResult(cmd.commandId, n.token, { ok, message, data });
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
  }
@@ -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,
@@ -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 {