@myagentroam/node 0.13.13 → 0.13.15

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/dist/connector.js CHANGED
@@ -13,7 +13,7 @@ import { NodeModelUsageService } from './service/model-usage-service.js';
13
13
  import { dirname, resolve } from 'node:path';
14
14
  import { homedir } from 'node:os';
15
15
  import WebSocket from 'ws';
16
- import { createEnvelope, directNodeCommandSchema, remoteDesktopDescriptionSchema } from '@myagentroam/protocol';
16
+ import { createEnvelope, directNodeCommandSchema, remoteDesktopDescriptionSchema, remoteDesktopDescribeInputSchema } from '@myagentroam/protocol';
17
17
  import { loadNodeConfig, computerUseRuntimeExecutable, directTransferUdpPortRange, nodeDataDirectory, nodeDatabasePath } from './config.js';
18
18
  import { NodeMetrics, nodeLog } from './operational.js';
19
19
  import { assertLocalSqlitePath } from './storage.js';
@@ -1550,16 +1550,30 @@ export class NodeConnector {
1550
1550
  service: this.agentNetworkService,
1551
1551
  observations: this.agentNetworkObservations
1552
1552
  }));
1553
- this.operationRouter.register('desktop.describe', async () => {
1553
+ this.operationRouter.register('desktop.describe', async (data) => {
1554
+ if (!isPlainRecord(data))
1555
+ throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
1556
+ const { __workspaceWatchSessionHash: _sessionHash, __requestUserId: _userId, __usageGrant: _usageGrant, __marctlCaller: _caller, ...input } = data;
1557
+ const parsed = remoteDesktopDescribeInputSchema.safeParse(input);
1558
+ if (!parsed.success)
1559
+ throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
1560
+ const selection = parsed.data;
1554
1561
  const runtime = this.desktopRuntime;
1555
1562
  if (!runtime)
1556
1563
  throw new RemoteDesktopError('DESKTOP_UNAVAILABLE');
1557
1564
  if (runtime instanceof WindowsRemoteDesktopRuntime ||
1558
1565
  runtime instanceof MacosRemoteDesktopRuntime)
1559
1566
  await runtime.refresh(AbortSignal.timeout(10_000));
1560
- const description = remoteDesktopDescriptionSchema.safeParse(runtime.describe());
1567
+ const description = remoteDesktopDescriptionSchema.safeParse(runtime instanceof MacosRemoteDesktopRuntime
1568
+ ? runtime.describe(selection)
1569
+ : runtime.describe());
1561
1570
  if (!description.success)
1562
1571
  throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
1572
+ if ((selection.desktopGeneration !== undefined &&
1573
+ selection.desktopGeneration !== description.data.desktopGeneration) ||
1574
+ (selection.displayId !== undefined &&
1575
+ !description.data.displays.some((display) => display.displayId === selection.displayId)))
1576
+ throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
1563
1577
  return description.data;
1564
1578
  });
1565
1579
  this.operationRouter.registerAll(this.promptCompletion.operations());
@@ -1 +1 @@
1
- {"type":"module","version":"0.13.13"}
1
+ {"type":"module","version":"0.13.15"}
@@ -27,13 +27,7 @@ export function validateServerUrl(value, translate = createTranslator()) {
27
27
  throw new MarctlError(translate('errors.serverUrlInvalid'));
28
28
  }
29
29
 
30
- const isLoopbackHttp =
31
- url.protocol === 'http:' &&
32
- (url.hostname === 'localhost' ||
33
- url.hostname === '127.0.0.1' ||
34
- url.hostname === '::1' ||
35
- url.hostname === '[::1]');
36
- if (url.protocol !== 'https:' && !isLoopbackHttp)
30
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
37
31
  throw new MarctlError(translate('errors.serverUrlTransport'));
38
32
  if (url.username || url.password || url.search || url.hash || url.pathname !== '/')
39
33
  throw new MarctlError(translate('errors.serverUrlOrigin'));
@@ -234,7 +234,7 @@ No configuration, authorization, or Server connection is required.`
234
234
  userContextUnavailable: 'This Node session has no valid marctl user authorization.',
235
235
  sessionContextRequired: 'No current Session is available; specify a Session ID explicitly.',
236
236
  serverUrlInvalid: 'MAR_CTL_SERVER_URL is invalid.',
237
- serverUrlTransport: 'MAR_CTL_SERVER_URL must use HTTPS or loopback HTTP.',
237
+ serverUrlTransport: 'MAR_CTL_SERVER_URL must use HTTP or HTTPS.',
238
238
  serverUrlOrigin: 'MAR_CTL_SERVER_URL must be an origin without credentials or query data.',
239
239
  fetchUnavailable: 'Fetch is unavailable in this Node.js runtime.',
240
240
  serverInvalidNodes: 'The server returned an invalid node list.',
@@ -220,7 +220,7 @@ ${configurationHelp}`,
220
220
  userContextUnavailable: '此 Node 会话没有有效的 marctl 用户授权。',
221
221
  sessionContextRequired: '当前上下文没有会话,请显式指定会话 ID。',
222
222
  serverUrlInvalid: 'MAR_CTL_SERVER_URL 无效。',
223
- serverUrlTransport: 'MAR_CTL_SERVER_URL 必须使用 HTTPS 或回环 HTTP。',
223
+ serverUrlTransport: 'MAR_CTL_SERVER_URL 必须使用 HTTP 或 HTTPS。',
224
224
  serverUrlOrigin: 'MAR_CTL_SERVER_URL 必须是不含凭据或查询参数的 Origin。',
225
225
  fetchUnavailable: '当前 Node.js 运行时没有 Fetch。',
226
226
  serverInvalidNodes: 'Server 返回的节点列表无效。',
@@ -55,67 +55,161 @@ async function queryScreens(binary, signal) {
55
55
  throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
56
56
  return parsed.displays;
57
57
  }
58
- /** Owns the current interactive macOS desktop layout and one shared macVNC backend. */
58
+ /** Owns per-display capture identities and backend pools; selecting a display never changes another page. */
59
59
  export class MacosRemoteDesktopRuntime {
60
60
  options;
61
- #pool;
62
- #generation = randomUUID();
63
- #layout;
61
+ #pools = new Set();
62
+ #entries = new Map();
63
+ #screens = [];
64
64
  #signature = '';
65
65
  #revision = 0;
66
66
  #disposed = false;
67
+ #topologyAvailable = false;
68
+ #refreshTail = Promise.resolve();
67
69
  constructor(options) {
68
70
  this.options = options;
69
- this.#pool = new TigerVncBackendPool((signal, onUnexpectedExit) => MacVncManagedProcess.start({ binaryPath: options.binaryPath, onUnexpectedExit }, signal), () => options.fence('DESKTOP_BACKEND_CLOSED'));
71
+ }
72
+ #createPool(displayId) {
73
+ const pool = new TigerVncBackendPool(async (signal, onUnexpectedExit) => {
74
+ const signature = this.#signature;
75
+ const screens = await this.#probeTopology(signal);
76
+ if (JSON.stringify(screens) !== this.#signature)
77
+ this.#invalidateTopology();
78
+ const displayIndex = screens.findIndex((screen) => screen.id === displayId);
79
+ if (this.#disposed ||
80
+ !this.#topologyAvailable ||
81
+ displayIndex < 0 ||
82
+ this.#signature !== signature)
83
+ throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
84
+ const backend = await MacVncManagedProcess.start({ binaryPath: this.options.binaryPath, displayIndex, onUnexpectedExit }, signal);
85
+ // The pool owns cleanup before the post-launch check can fail. A failed stop
86
+ // must retain the backend/reference so its attachment can retry cleanup.
87
+ return {
88
+ stop: () => backend.stop(),
89
+ connect: async (maxPixels, connectSignal) => {
90
+ const current = await this.#probeTopology(connectSignal);
91
+ if (JSON.stringify(current) !== this.#signature)
92
+ this.#invalidateTopology();
93
+ if (this.#disposed || !this.#topologyAvailable || this.#signature !== signature)
94
+ throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
95
+ return backend.connect(maxPixels, connectSignal);
96
+ }
97
+ };
98
+ }, () => this.options.fence('DESKTOP_BACKEND_CLOSED'));
99
+ this.#pools.add(pool);
100
+ return pool;
70
101
  }
71
102
  static async start(options, signal) {
72
103
  const runtime = new MacosRemoteDesktopRuntime(options);
73
104
  await runtime.refresh(signal);
74
105
  return runtime;
75
106
  }
76
- async refresh(signal) {
77
- const screens = (await queryScreens(this.options.topologyBinary, signal)).sort((left, right) => left.id - right.id);
107
+ refresh(signal) {
108
+ const result = this.#refreshTail.then(() => this.#refresh(signal));
109
+ this.#refreshTail = result.catch(() => { });
110
+ return result;
111
+ }
112
+ #invalidateTopology() {
113
+ const wasAvailable = this.#topologyAvailable;
114
+ this.#topologyAvailable = false;
115
+ if (wasAvailable)
116
+ this.options.fence('DESKTOP_TOPOLOGY_UNAVAILABLE');
117
+ }
118
+ async #probeTopology(signal) {
119
+ if (this.#disposed)
120
+ throw new RemoteDesktopError('DESKTOP_BACKEND_CLOSED');
121
+ try {
122
+ return await queryScreens(this.options.topologyBinary, signal);
123
+ }
124
+ catch (error) {
125
+ // Closing one page aborts its probe; it is not evidence of desktop loss.
126
+ if (!signal.aborted)
127
+ this.#invalidateTopology();
128
+ throw error;
129
+ }
130
+ }
131
+ async #refresh(signal) {
132
+ // Keep native enumeration order: macVNC's -display takes an index, not a display ID.
133
+ const screens = await this.#probeTopology(signal);
78
134
  const signature = JSON.stringify(screens);
79
135
  if (this.#disposed)
80
136
  throw new RemoteDesktopError('DESKTOP_BACKEND_CLOSED');
81
- if (signature === this.#signature)
137
+ if (signature === this.#signature && this.#topologyAvailable)
82
138
  return;
83
- const layout = macosCombinedDesktopLayout(screens.filter((screen) => screen.primary), this.#generation, this.#revision + 1);
84
- if (layout.framebuffer.width * layout.framebuffer.height > this.options.maxPixels)
139
+ // Revoke old captures before validating new geometry. A failed probe/layout
140
+ // must not leave the previous grants current or revive them after recovery.
141
+ this.#invalidateTopology();
142
+ const selectableDisplays = screens.map((screen) => ({
143
+ displayId: `display_${screen.id}`,
144
+ name: `Display ${screen.id}`,
145
+ primary: screen.primary
146
+ }));
147
+ const layouts = screens.map((screen) => {
148
+ const displayId = `display_${screen.id}`;
149
+ const layout = remoteDesktopDescriptionSchema.parse({
150
+ ...macosCombinedDesktopLayout([{ ...screen, primary: true }], this.#entries.get(displayId)?.layout.desktopGeneration ?? randomUUID(), this.#revision + 1),
151
+ selectableDisplays
152
+ });
153
+ if (layout.framebuffer.width * layout.framebuffer.height > this.options.maxPixels)
154
+ throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
155
+ return { screen, displayId, layout };
156
+ });
157
+ if (!layouts.length)
85
158
  throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
86
- if (this.#layout)
87
- this.options.fence('DESKTOP_TOPOLOGY_UNAVAILABLE');
159
+ this.#entries = new Map(layouts.map(({ screen, displayId, layout }) => [
160
+ displayId,
161
+ { layout, pool: this.#entries.get(displayId)?.pool ?? this.#createPool(screen.id) }
162
+ ]));
163
+ this.#screens = screens;
88
164
  this.#signature = signature;
89
165
  this.#revision++;
90
- this.#layout = layout;
166
+ this.#topologyAvailable = true;
91
167
  }
92
- describe() {
93
- if (this.#disposed || !this.#layout || !this.#pool.isAvailable())
168
+ describe(selection = {}) {
169
+ const entry = selection.desktopGeneration !== undefined
170
+ ? [...this.#entries.values()].find((candidate) => candidate.layout.desktopGeneration === selection.desktopGeneration)
171
+ : this.#entries.get(selection.displayId ?? `display_${this.#screens.find((screen) => screen.primary)?.id}`);
172
+ if (this.#disposed)
94
173
  throw new RemoteDesktopError('DESKTOP_BACKEND_CLOSED');
95
- return structuredClone(this.#layout);
174
+ if (!this.#topologyAvailable)
175
+ throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
176
+ if (!entry)
177
+ throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
178
+ if (!entry.pool.isAvailable())
179
+ throw new RemoteDesktopError('DESKTOP_BACKEND_CLOSED');
180
+ return structuredClone(entry.layout);
96
181
  }
97
182
  isCurrent = (command) => !this.#disposed &&
98
- this.#pool.isAvailable() &&
99
- !!this.#layout &&
100
- this.#layout.desktopGeneration === command.desktopGeneration &&
101
- this.#layout.topologyRevision === command.topologyRevision;
102
- createAttachment = (command, frontend, callbacks) => new PooledDesktopAttachment(this.#pool, frontend, (backend) => new RemoteDesktopAttachment({
103
- attachmentId: command.attachmentId,
104
- frontend,
105
- backend,
106
- mode: command.mode,
107
- maxPixels: this.options.maxPixels,
108
- isCurrentTopology: (width, height) => this.isCurrent(command) &&
109
- this.#layout?.framebuffer.width === width &&
110
- this.#layout.framebuffer.height === height,
111
- onAttached: callbacks.attached,
112
- onFailure: callbacks.failed
113
- }));
183
+ this.#topologyAvailable &&
184
+ [...this.#entries.values()].some((entry) => entry.pool.isAvailable() &&
185
+ entry.layout.desktopGeneration === command.desktopGeneration &&
186
+ entry.layout.topologyRevision === command.topologyRevision);
187
+ createAttachment = (command, frontend, callbacks) => {
188
+ const entry = [...this.#entries.values()].find((candidate) => candidate.layout.desktopGeneration === command.desktopGeneration);
189
+ if (!entry || !this.isCurrent(command))
190
+ throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
191
+ return new PooledDesktopAttachment(entry.pool, frontend, (backend) => new RemoteDesktopAttachment({
192
+ attachmentId: command.attachmentId,
193
+ frontend,
194
+ backend,
195
+ mode: command.mode,
196
+ maxPixels: this.options.maxPixels,
197
+ isCurrentTopology: (width, height) => this.isCurrent(command) &&
198
+ entry.layout.framebuffer.width === width &&
199
+ entry.layout.framebuffer.height === height,
200
+ onAttached: callbacks.attached,
201
+ onFailure: callbacks.failed
202
+ }));
203
+ };
114
204
  async dispose() {
115
205
  if (!this.#disposed) {
116
206
  this.#disposed = true;
117
207
  this.options.fence('DESKTOP_SESSION_UNAVAILABLE');
118
208
  }
119
- await this.#pool.dispose();
209
+ const results = await Promise.allSettled([...this.#pools].map((pool) => pool.dispose()));
210
+ for (const result of results)
211
+ if (result.status === 'rejected')
212
+ throw result.reason;
213
+ this.#pools.clear();
120
214
  }
121
215
  }
@@ -47,6 +47,11 @@ export class MacVncManagedProcess {
47
47
  static async start(options, signal) {
48
48
  if (process.platform !== 'darwin' || !isAbsolute(options.binaryPath))
49
49
  throw new RemoteDesktopError('DESKTOP_BACKEND_CONFIG_INVALID');
50
+ if (options.displayIndex !== undefined &&
51
+ (!Number.isInteger(options.displayIndex) ||
52
+ options.displayIndex < 0 ||
53
+ options.displayIndex >= 64))
54
+ throw new RemoteDesktopError('DESKTOP_BACKEND_CONFIG_INVALID');
50
55
  const deadline = AbortSignal.any([signal, AbortSignal.timeout(10_000)]);
51
56
  if (deadline.aborted)
52
57
  throw new RemoteDesktopError('DESKTOP_SETUP_CANCELLED');
@@ -79,7 +84,8 @@ export class MacVncManagedProcess {
79
84
  '127.0.0.1',
80
85
  '-rfbauth',
81
86
  config,
82
- '-alwaysshared'
87
+ '-alwaysshared',
88
+ ...(options.displayIndex === undefined ? [] : ['-display', String(options.displayIndex)])
83
89
  ], {
84
90
  cwd: dirname(options.binaryPath),
85
91
  windowsHide: true,
@@ -47,7 +47,7 @@ export class NodeRequestService {
47
47
  await this.options.authorizeMarctlOperation(marctlCaller, payload.operation, payload.data, authorization.userId);
48
48
  }
49
49
  const usageGrant = payload.userAuthorization.usageGrant;
50
- const data = payload.data !== null && typeof payload.data === 'object'
50
+ const data = payload.data !== null && typeof payload.data === 'object' && !Array.isArray(payload.data)
51
51
  ? (() => {
52
52
  const { __marctlCaller: _untrustedCaller, __usageGrant: _untrustedGrant, __marctlExecutionResources: _untrustedExecutionResources, __requestAbortSignal: _untrustedSignal, ...input } = payload.data;
53
53
  return {
@@ -18,11 +18,7 @@ export class NodeUpgradeReporter {
18
18
  endpoint.protocol = 'http:';
19
19
  if (endpoint.protocol === 'wss:')
20
20
  endpoint.protocol = 'https:';
21
- if (endpoint.username ||
22
- endpoint.password ||
23
- (endpoint.protocol !== 'https:' &&
24
- !(endpoint.protocol === 'http:' &&
25
- ['localhost', '127.0.0.1', '[::1]'].includes(endpoint.hostname))))
21
+ if (endpoint.username || endpoint.password || !['http:', 'https:'].includes(endpoint.protocol))
26
22
  throw new Error('NODE_UPGRADE_REPORT_ORIGIN_INVALID');
27
23
  endpoint.pathname = `/api/nodes/${encodeURIComponent(nodeId)}/upgrade/report`;
28
24
  endpoint.search = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.13.13",
3
+ "version": "0.13.15",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/myagentroam/mar.git",
@@ -37,7 +37,7 @@
37
37
  "dependencies": {
38
38
  "@anthropic-ai/claude-agent-sdk": "0.3.233",
39
39
  "@modelcontextprotocol/sdk": "1.32.0",
40
- "@myagentroam/binaries": "0.10.3",
40
+ "@myagentroam/binaries": "0.10.4",
41
41
  "@opencode-ai/sdk": "1.18.20",
42
42
  "minimatch": "^10.2.6",
43
43
  "node-datachannel": "0.33.4",
@@ -49,13 +49,13 @@
49
49
  "turndown-plugin-gfm": "^1.0.2",
50
50
  "ws": "^8.21.3",
51
51
  "zod": "4.4.3",
52
- "@myagentroam/protocol": "0.13.13",
53
- "@myagentroam/agent": "0.13.13"
52
+ "@myagentroam/agent": "0.13.15",
53
+ "@myagentroam/protocol": "0.13.15"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/turndown": "^5.0.6",
57
57
  "@types/ws": "^8.18.1",
58
- "@myagentroam/marctl": "0.13.13",
58
+ "@myagentroam/marctl": "0.13.15",
59
59
  "@myagentroam/code-navigation": "0.11.1"
60
60
  },
61
61
  "exports": {
@@ -76,6 +76,7 @@
76
76
  "test:unit": "vitest run --config vitest.config.mts && node --test test/unit/*.test.mjs",
77
77
  "test:e2e:real": "vitest run --config vitest.e2e.config.mts",
78
78
  "test:e2e:computer-use:windows": "node --test test/e2e/computer-use-windows.test.mjs",
79
- "test:e2e:remote-desktop:windows": "node --test test/e2e/remote-desktop-windows.test.mjs"
79
+ "test:e2e:remote-desktop:windows": "node --test test/e2e/remote-desktop-windows.test.mjs",
80
+ "test:e2e:remote-desktop:macos": "node --test test/e2e/remote-desktop-macos.test.mjs"
80
81
  }
81
82
  }