@myagentroam/node 0.9.152 → 0.9.153

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
@@ -72,7 +72,7 @@ import { SessionCatalogService } from './service/session-catalog-service.js';
72
72
  import { SessionCommandService } from './service/session-command-service.js';
73
73
  import { SessionPresentationService } from './service/session-presentation-service.js';
74
74
  import { isPlainRecord, isRunnerImageAttachment, parseRunnerImageAttachments } from './runner/codex/conversation-parser.js';
75
- import { readWebSearchError } from './service/web-search-error.js';
75
+ import { WebToolService } from './service/web-tool-service.js';
76
76
  export { nodeConnectEndpoint, validatedCapabilities } from './runner/codex/conversation-parser.js';
77
77
  import { NodeControlChannel } from './service/node-control-channel.js';
78
78
  import { NodeControlMessageService } from './service/node-control-message-service.js';
@@ -168,6 +168,7 @@ export class NodeConnector {
168
168
  send: (envelope) => this.sendEnvelope(envelope)
169
169
  });
170
170
  marAgentRuntimeModels = new MarAgentRuntimeModelFactory(this.runtimeCredentials);
171
+ webToolService = new WebToolService(() => this.config);
171
172
  marAgentModelTransportTests = new MarAgentModelTransportTestService({
172
173
  modelFactory: this.marAgentRuntimeModels
173
174
  });
@@ -589,7 +590,8 @@ export class NodeConnector {
589
590
  setCommandState: (sessionId, state) => this.commandStates.set(sessionId, state),
590
591
  clearCommandState: (sessionId, commandId) => this.commandStates.clear(sessionId, commandId),
591
592
  commandStates: (sessionId) => this.commandStates.list(sessionId),
592
- webSearch: (input, signal) => this.webSearch(input, signal),
593
+ webSearch: (input, signal) => this.webToolService.webSearch(input, signal),
594
+ webFetch: (input, signal) => this.webToolService.webFetch(input, signal),
593
595
  registerGeneratedImages: (runId, nativeSessionId, images) => {
594
596
  const turn = this.runtime.conversationTurnForRun(runId);
595
597
  const session = turn === undefined ? undefined : this.runtime.getAgentSession(turn.sessionId);
@@ -1536,7 +1538,8 @@ export class NodeConnector {
1536
1538
  await templateService.recoverInterruptedPublications();
1537
1539
  await store.rebuildConversationIndex();
1538
1540
  runtime = new MarAgentHostedConversationRuntime({
1539
- webSearch: (input, signal) => this.webSearch(input, signal),
1541
+ webSearch: (input, signal) => this.webToolService.webSearch(input, signal),
1542
+ webFetch: (input, signal) => this.webToolService.webFetch(input, signal, false),
1540
1543
  modelFactory: this.marAgentRuntimeModels,
1541
1544
  registerNativeImages: (runId, roots, images) => this.runAttachmentService.registerNativeImages(runId, roots, images, {
1542
1545
  reuseCachedRunImages: false
@@ -2021,6 +2024,7 @@ export class NodeConnector {
2021
2024
  this.scheduledRunAuthorization.close();
2022
2025
  this.mailNotificationControl.close();
2023
2026
  const mailMcpDrain = this.mailNotificationMcpServer.close();
2027
+ const webToolDrain = this.webToolService.close();
2024
2028
  this.directTransferService?.dispose();
2025
2029
  this.directTransferService = undefined;
2026
2030
  this.controlChannel.stop();
@@ -2033,7 +2037,8 @@ export class NodeConnector {
2033
2037
  uploadDrain,
2034
2038
  goalMcpDrain,
2035
2039
  scheduledMcpDrain,
2036
- mailMcpDrain
2040
+ mailMcpDrain,
2041
+ webToolDrain
2037
2042
  ]);
2038
2043
  this.stopping = (async () => {
2039
2044
  try {
@@ -2202,25 +2207,6 @@ export class NodeConnector {
2202
2207
  });
2203
2208
  return this.gitExecutionService;
2204
2209
  }
2205
- async webSearch(input, signal) {
2206
- const config = this.config;
2207
- if (config?.nodeId === undefined || config.credential === undefined)
2208
- throw new Error('NODE_UNAUTHENTICATED');
2209
- const response = await fetch(`${config.serverUrl.replace(/\/$/u, '')}/api/node-tools/web-search`, {
2210
- method: 'POST',
2211
- headers: {
2212
- 'content-type': 'application/json',
2213
- 'x-mar-node-id': config.nodeId,
2214
- authorization: `Bearer ${config.credential}`
2215
- },
2216
- body: JSON.stringify(input),
2217
- signal
2218
- });
2219
- if (!response.ok)
2220
- throw await readWebSearchError(response);
2221
- const result = await response.json();
2222
- return { content: JSON.stringify(result), data: result };
2223
- }
2224
2210
  publishGoalState(session, goal) {
2225
2211
  if (goal.status !== 'active') {
2226
2212
  this.commandStates.clear(session.id, 'goal');
package/dist/main.js CHANGED
@@ -1,16 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { NodeConnector } from './connector.js';
4
+ import { spawnSync } from 'node:child_process';
5
5
  import { loadNodeConfig, nodeConfigPath, nodeDatabasePath, nodeDataDirectory } from './config.js';
6
6
  import { getNodeHealth } from './health.js';
7
- import { removeServiceDefinition, writeServiceDefinition } from './service.js';
7
+ import { browserCachePath, removeServiceDefinition, writeServiceDefinition } from './service.js';
8
8
  import { superviseNode } from './supervisor.js';
9
9
  import { configureNodeLog } from './rotating-log.js';
10
10
  const [command = 'status', ...args] = process.argv.slice(2);
11
11
  const config = readOption(args, '--config') ?? nodeConfigPath();
12
12
  const output = readOption(args, '--output') ?? defaultServicePath();
13
+ const browserPath = readOption(args, '--browser-cache');
14
+ if (browserPath !== undefined)
15
+ process.env['PLAYWRIGHT_BROWSERS_PATH'] = browserPath;
13
16
  if (command === 'run') {
17
+ const { NodeConnector } = await import('./connector.js');
14
18
  configureNodeLog(nodeDataDirectory(await loadNodeConfig(config), config));
15
19
  const connector = new NodeConnector({ configPath: config });
16
20
  await connector.start();
@@ -54,5 +58,9 @@ function defaultServicePath() {
54
58
  }
55
59
  async function writeCurrentServiceDefinition(output, configPath) {
56
60
  const nodeConfig = await loadNodeConfig(configPath);
57
- await writeServiceDefinition(output, process.execPath, configPath, process.platform, process.argv[1], [dirname(nodeDatabasePath(nodeConfig, configPath)), ...nodeConfig.allowedRoots]);
61
+ const cachePath = browserCachePath(nodeDataDirectory(nodeConfig, configPath));
62
+ const browser = spawnSync(process.execPath, [resolve(import.meta.dirname, '../scripts/install-web-browser.mjs')], { stdio: 'inherit', env: { ...process.env, PLAYWRIGHT_BROWSERS_PATH: cachePath } });
63
+ if (browser.error || browser.signal || browser.status !== 0)
64
+ throw new Error('NODE_WEB_BROWSER_INSTALL_FAILED');
65
+ await writeServiceDefinition(output, process.execPath, configPath, process.platform, process.argv[1], [dirname(nodeDatabasePath(nodeConfig, configPath)), ...nodeConfig.allowedRoots], cachePath);
58
66
  }
@@ -0,0 +1,21 @@
1
+ import { MarAgentError } from '@myagentroam/agent';
2
+ /** Composes Node-owned tools without replacing another domain's execution handler. */
3
+ export function combineExecutionExtensions(...sources) {
4
+ const tools = sources.flatMap((source) => source.tools ?? []);
5
+ if (new Set(tools.map((tool) => tool.name)).size !== tools.length)
6
+ throw new MarAgentError('MAR_AGENT_TOOL_INPUT_INVALID', 'Host tool names conflict.');
7
+ return {
8
+ tools,
9
+ skills: sources.flatMap((source) => source.skills ?? []),
10
+ isToolAvailable: (name, context) => {
11
+ const source = sources.find((source) => source.tools?.some((tool) => tool.name === name));
12
+ return source !== undefined && source.isToolAvailable?.(name, context) !== false;
13
+ },
14
+ executeTool: async (call, context) => {
15
+ const source = sources.find((source) => source.tools?.some((tool) => tool.name === call.name));
16
+ if (!source?.executeTool)
17
+ throw new MarAgentError('MAR_AGENT_TOOL_NOT_AVAILABLE', 'Host tool is unavailable.');
18
+ return source.executeTool(call, context);
19
+ }
20
+ };
21
+ }
@@ -6,6 +6,8 @@ import { hostedConversationGeneratedImagesDisplayDirectory, hostedConversationIm
6
6
  import { MarAgentMcpClientPool } from './mcp-client-pool.js';
7
7
  import { MarAgentRuntimeModelFactory, sdkModelConfigurations, closeImageCredentialProviders } from './runtime-model-factory.js';
8
8
  import { createImageGenerationExtensions } from './image-generation-extensions.js';
9
+ import { createWebToolsExtensions } from './web-tools-extensions.js';
10
+ import { combineExecutionExtensions } from './execution-extensions.js';
9
11
  /**
10
12
  * Opens one short-lived MAR Agent Host per hosted Run so template mounts cannot leak across Run
11
13
  * boundaries. Conversation-scoped tmp and Session history may remain available to later Runs.
@@ -18,6 +20,7 @@ export class MarAgentHostedConversationRuntime {
18
20
  #now;
19
21
  #onEvent;
20
22
  #webSearch;
23
+ #webFetch;
21
24
  #active = new Map();
22
25
  #modelFactory;
23
26
  #registerNativeImages;
@@ -29,6 +32,7 @@ export class MarAgentHostedConversationRuntime {
29
32
  this.#now = options.now ?? Date.now;
30
33
  this.#onEvent = options.onEvent;
31
34
  this.#webSearch = options.webSearch;
35
+ this.#webFetch = options.webFetch;
32
36
  this.#registerNativeImages = options.registerNativeImages;
33
37
  this.#modelFactory =
34
38
  options.modelFactory ??
@@ -275,7 +279,7 @@ export class MarAgentHostedConversationRuntime {
275
279
  executionId: 'clientMessageId' in input ? input.clientMessageId : input.conversationId
276
280
  });
277
281
  const selectedModelId = 'defaultModelId' in input ? input.defaultModelId : input.modelId;
278
- const extensions = createImageGenerationExtensions({
282
+ const imageExtensions = createImageGenerationExtensions({
279
283
  configuration: models.find((model) => model.id === selectedModelId)?.imageGeneration,
280
284
  homeDirectory: input.homeDirectory,
281
285
  ...(generatedImageDisplayDirectory === undefined
@@ -283,6 +287,16 @@ export class MarAgentHostedConversationRuntime {
283
287
  : { displayDirectory: generatedImageDisplayDirectory }),
284
288
  host: () => host
285
289
  });
290
+ const webAccess = 'webAccess' in input && input.webAccess;
291
+ const extensions = combineExecutionExtensions(imageExtensions, createWebToolsExtensions({
292
+ enabled: webAccess,
293
+ hostedSearchModelIds: models
294
+ .filter((model) => model.hostedWebSearch)
295
+ .map((model) => model.id),
296
+ hostedWebSearch: models.find((model) => model.id === selectedModelId)?.hostedWebSearch ?? false,
297
+ ...(this.#webSearch === undefined ? {} : { webSearch: this.#webSearch }),
298
+ ...(this.#webFetch === undefined ? {} : { webFetch: this.#webFetch })
299
+ }));
286
300
  host = this.#createHost({
287
301
  extensions,
288
302
  mode: 'node',
@@ -294,17 +308,12 @@ export class MarAgentHostedConversationRuntime {
294
308
  restrictPaths: true,
295
309
  workspaceWritable: false,
296
310
  pathMounts: hostedPathMounts(input),
297
- webFetch: 'webAccess' in input && input.webAccess,
298
- ...('webAccess' in input && input.webAccess && this.#webSearch !== undefined
299
- ? { webSearch: this.#webSearch }
300
- : {}),
301
311
  ...(mcp === undefined
302
312
  ? {}
303
313
  : {
304
314
  mcpCatalog: () => mcp.catalog(),
305
315
  callMcp: (call, signal, context) => mcp.call(call.name, call.arguments, signal, context?.expectedTool)
306
- }),
307
- allowUntrustedTls: false
316
+ })
308
317
  });
309
318
  }
310
319
  catch (error) {
@@ -3,6 +3,7 @@ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { homedir } from 'node:os';
4
4
  import { access, constants } from 'node:fs/promises';
5
5
  import { nodeExecutionEnvironment } from '../../node-execution-environment.js';
6
+ import { nodeLog } from '../../operational.js';
6
7
  import { BUNDLED_RIPGREP_VERSION, bundledRipgrepPath, prependBundledRipgrepToPath } from '../../ripgrep-binary.js';
7
8
  import { LocalAgentHost, createMarAgent, generateSessionTitle } from '@myagentroam/agent';
8
9
  import { marAgentReasoningEffortSchema, marAgentRuntimeModelConfigurationSchema } from '@myagentroam/protocol';
@@ -14,6 +15,8 @@ import { parseRunnerImageAttachments } from '../../util/node-operation-parsers.j
14
15
  import { runnerErrorProjection } from '../../util/runner-error.js';
15
16
  import { MarAgentRuntimeModelFactory, sdkModelConfigurations, closeImageCredentialProviders } from './runtime-model-factory.js';
16
17
  import { createImageGenerationExtensions } from './image-generation-extensions.js';
18
+ import { createWebToolsExtensions } from './web-tools-extensions.js';
19
+ import { combineExecutionExtensions } from './execution-extensions.js';
17
20
  export class MarAgentManagedRunController {
18
21
  runner;
19
22
  host;
@@ -61,7 +64,7 @@ export class MarAgentManagedRunController {
61
64
  execution.cancelTitle();
62
65
  execution.cancel();
63
66
  }
64
- await Promise.all([...new Set(this.#sessionRuntimes.values())].map((runtime) => this.#releaseRuntime(runtime)));
67
+ await Promise.all([...new Set(this.#sessionRuntimes.values())].map((runtime) => this.#releaseRuntime(runtime, 'controller_disposed')));
65
68
  await this.#mcpClients.close();
66
69
  await this.#computerUse?.dispose();
67
70
  })();
@@ -83,7 +86,7 @@ export class MarAgentManagedRunController {
83
86
  execution?.cancelTitle();
84
87
  execution?.cancel();
85
88
  }
86
- await this.#releaseRuntime(runtime);
89
+ await this.#releaseRuntime(runtime, 'session_released');
87
90
  await Promise.all([
88
91
  this.#computerUse?.releaseSession(sessionId),
89
92
  this.#computerUse?.releaseSession(runtime.session.id)
@@ -279,21 +282,26 @@ export class MarAgentManagedRunController {
279
282
  const acquisition = this.#acquireRuntime(input);
280
283
  for (const key of acquisitionKeys)
281
284
  this.#runtimeAcquisitions.set(key, acquisition);
285
+ let acquired = false;
282
286
  try {
283
287
  runtime = await acquisition;
288
+ acquired = true;
284
289
  executionModels = runtime.models;
285
290
  }
286
291
  finally {
287
292
  for (const key of acquisitionKeys)
288
293
  if (this.#runtimeAcquisitions.get(key) === acquisition)
289
294
  this.#runtimeAcquisitions.delete(key);
295
+ // A failed acquisition may have retained an idle Runtime before setup failed.
296
+ if (!acquired)
297
+ this.#restoreRuntimeRelease(this.#sessionRuntimes.get(sessionKey));
290
298
  }
291
299
  controller.signal.throwIfAborted();
292
300
  if (this.#disposed ||
293
301
  this.#sessionReleases.has(sessionKey) ||
294
302
  this.#sessionReleases.has(input.sessionId) ||
295
303
  this.#sessionReleases.has(runtime.session.id)) {
296
- await this.#releaseRuntime(runtime);
304
+ await this.#releaseRuntime(runtime, 'admission_released');
297
305
  throw new Error('MAR_AGENT_SESSION_RUNTIME_RELEASED');
298
306
  }
299
307
  if (runtime.activeRunId !== undefined)
@@ -341,12 +349,23 @@ export class MarAgentManagedRunController {
341
349
  homeDirectory: homedir(),
342
350
  host: () => runtime.host
343
351
  });
352
+ const extensions = combineExecutionExtensions(imageExtensions, createWebToolsExtensions({
353
+ enabled: true,
354
+ hostedSearchModelIds: executionModels
355
+ .filter((model) => model.hostedWebSearch)
356
+ .map((model) => model.id),
357
+ hostedWebSearch: executionModels.find((model) => model.id === input.model.id)?.hostedWebSearch ?? false,
358
+ webSearch: (request, signal) => this.host.webSearch(request, signal),
359
+ ...(this.host.webFetch === undefined
360
+ ? {}
361
+ : { webFetch: (request, signal) => this.host.webFetch(request, signal) })
362
+ }));
344
363
  executionMcpBinding = runtime.host.bindExecutionExtensions({
345
- ...imageExtensions,
364
+ ...extensions,
346
365
  environment: input.secretEnvironment?.['MAR_CTL_USER_TOKEN'] === undefined
347
366
  ? {}
348
367
  : { MAR_CTL_USER_TOKEN: input.secretEnvironment['MAR_CTL_USER_TOKEN'] },
349
- skills: [...(imageExtensions.skills ?? []), computerUseHostSkill]
368
+ skills: [...(extensions.skills ?? []), computerUseHostSkill]
350
369
  }, {
351
370
  tools: [...nodeTools, ...userTools],
352
371
  catalog: () => {
@@ -488,16 +507,16 @@ export class MarAgentManagedRunController {
488
507
  }
489
508
  }
490
509
  #prepareRuntimeForTerminal(runId, runtime) {
491
- if (runtime.activeRunId === runId) {
492
- delete runtime.activeRunId;
493
- }
510
+ if (runtime.activeRunId !== runId)
511
+ return;
512
+ delete runtime.activeRunId;
494
513
  this.#scheduleRuntimeRelease(runtime);
495
514
  }
496
515
  async #finishRuntimeExecution(runId, runtime) {
497
- if (runtime.activeRunId === runId) {
498
- delete runtime.activeRunId;
499
- }
500
- this.#scheduleRuntimeRelease(runtime);
516
+ if (runtime.activeRunId === runId)
517
+ this.#prepareRuntimeForTerminal(runId, runtime);
518
+ else
519
+ this.#restoreRuntimeRelease(runtime);
501
520
  }
502
521
  async #acquireRuntime(input) {
503
522
  if (this.#disposed)
@@ -553,9 +572,14 @@ export class MarAgentManagedRunController {
553
572
  existing.personalInstructions = personalInstructions;
554
573
  }
555
574
  this.#retainRuntime(sessionKey);
575
+ nodeLog('runner.mar-agent.runtime.reused', {
576
+ runtimeId: existing.runtimeId,
577
+ sessionId: existing.session.id,
578
+ runId: input.runId
579
+ });
556
580
  return existing;
557
581
  }
558
- await this.#releaseRuntime(existing);
582
+ await this.#releaseRuntime(existing, 'host_configuration_changed');
559
583
  }
560
584
  const creation = this.#createRuntime(input, sessionKey, fingerprint, hostConfiguration);
561
585
  for (const key of creationKeys)
@@ -598,8 +622,6 @@ export class MarAgentManagedRunController {
598
622
  },
599
623
  mcpCatalog: () => mcp.catalog(),
600
624
  callMcp: async (call, signal, context) => mcp.call(call.name, call.arguments, signal, context?.expectedTool),
601
- webSearch: (request, signal) => this.host.webSearch(request, signal),
602
- allowUntrustedTls: allowsUntrustedWebFetchTls(hostConfiguration.environment),
603
625
  resourceIdleTtlMs: this.#resourceIdleTtlMs
604
626
  });
605
627
  models = this.#modelFactory.create(input.models, {
@@ -617,6 +639,7 @@ export class MarAgentManagedRunController {
617
639
  ? await agent.openSession(input.externalSessionId)
618
640
  : await agent.createSession({ workspace: input.cwd });
619
641
  runtime = {
642
+ runtimeId: randomUUID(),
620
643
  fingerprint,
621
644
  agent,
622
645
  session,
@@ -631,6 +654,11 @@ export class MarAgentManagedRunController {
631
654
  };
632
655
  this.#sessionRuntimes.set(sessionKey, runtime);
633
656
  this.#sessionRuntimes.set(session.id, runtime);
657
+ nodeLog('runner.mar-agent.runtime.created', {
658
+ runtimeId: runtime.runtimeId,
659
+ sessionId: session.id,
660
+ runId: input.runId
661
+ });
634
662
  if (!input.externalSessionId)
635
663
  this.host.promote(input.sessionId, session.id);
636
664
  return runtime;
@@ -642,16 +670,26 @@ export class MarAgentManagedRunController {
642
670
  throw error;
643
671
  }
644
672
  }
673
+ #runtimeInUse(runtime) {
674
+ return (runtime.activeRunId !== undefined ||
675
+ [...this.#runtimeAcquisitions.keys()].some((key) => this.#sessionRuntimes.get(key) === runtime));
676
+ }
645
677
  #scheduleRuntimeRelease(runtime) {
646
- if (runtime.released || runtime.release !== undefined)
678
+ // Old finalizers must neither reclaim a continuation nor extend its idle lease.
679
+ if (runtime.released ||
680
+ runtime.release !== undefined ||
681
+ runtime.idleTimer !== undefined ||
682
+ this.#runtimeInUse(runtime))
647
683
  return;
648
- if (runtime.idleTimer !== undefined)
649
- clearTimeout(runtime.idleTimer);
650
- runtime.idleTimer = setTimeout(() => {
684
+ const timer = setTimeout(() => {
685
+ if (runtime.idleTimer !== timer)
686
+ return;
651
687
  delete runtime.idleTimer;
652
- void this.#releaseRuntime(runtime);
688
+ if (!this.#runtimeInUse(runtime))
689
+ void this.#releaseRuntime(runtime, 'idle_expired');
653
690
  }, this.#resourceIdleTtlMs);
654
- runtime.idleTimer.unref();
691
+ runtime.idleTimer = timer;
692
+ timer.unref();
655
693
  }
656
694
  #retainRuntime(sessionKey) {
657
695
  const runtime = this.#sessionRuntimes.get(sessionKey);
@@ -670,11 +708,17 @@ export class MarAgentManagedRunController {
670
708
  return;
671
709
  this.#scheduleRuntimeRelease(runtime);
672
710
  }
673
- #releaseRuntime(runtime) {
711
+ #releaseRuntime(runtime, reason) {
674
712
  if (runtime.release !== undefined)
675
713
  return runtime.release;
676
714
  runtime.release = (async () => {
677
715
  runtime.released = true;
716
+ nodeLog('runner.mar-agent.runtime.releasing', {
717
+ runtimeId: runtime.runtimeId,
718
+ sessionId: runtime.session.id,
719
+ reason,
720
+ ...(runtime.activeRunId === undefined ? {} : { runId: runtime.activeRunId })
721
+ });
678
722
  if (runtime.idleTimer !== undefined) {
679
723
  clearTimeout(runtime.idleTimer);
680
724
  delete runtime.idleTimer;
@@ -811,9 +855,6 @@ function sessionModelFingerprint(input) {
811
855
  }))
812
856
  .digest('hex');
813
857
  }
814
- export function allowsUntrustedWebFetchTls(environment) {
815
- return environment['MAR_AGENT_WEB_FETCH_ALLOW_UNTRUSTED_TLS'] !== '0';
816
- }
817
858
  async function resolveMarAgentHostRuntimeConfiguration(secretEnvironment = {}) {
818
859
  const environment = nodeExecutionEnvironment({
819
860
  ...secretEnvironment,
@@ -0,0 +1,72 @@
1
+ import { MarAgentError } from '@myagentroam/agent';
2
+ import { WEB_FETCH_MAX_URL_LENGTH, webFetchRequestSchema, webSearchRequestSchema } from '../../service/web-fetch-contracts.js';
3
+ export const webSearchToolDefinition = {
4
+ name: 'web_search',
5
+ allowedCallers: ['direct', 'programmatic'],
6
+ parallelSafety: 'safe',
7
+ description: 'Search the web for current information using the Node search service. Use web_fetch to open a specific result.',
8
+ inputSchema: {
9
+ type: 'object',
10
+ properties: {
11
+ query: { type: 'string', maxLength: 500 },
12
+ maxResults: { type: 'integer', minimum: 1, maximum: 10, default: 8 }
13
+ },
14
+ required: ['query'],
15
+ additionalProperties: false
16
+ }
17
+ };
18
+ export const webFetchToolDefinition = {
19
+ name: 'web_fetch',
20
+ allowedCallers: ['direct', 'programmatic'],
21
+ parallelSafety: 'safe',
22
+ description: 'Read an HTTP or HTTPS URL from the Node network, including intranet URLs. Choose http for raw text/HTML without JavaScript, or browser for rendered visible text using an isolated headless Chromium. Modes never switch automatically; explicitly retry in the other mode when needed. No user browser login or cookies are shared.',
23
+ inputSchema: {
24
+ type: 'object',
25
+ properties: {
26
+ url: { type: 'string', maxLength: WEB_FETCH_MAX_URL_LENGTH },
27
+ mode: { type: 'string', enum: ['http', 'browser'] }
28
+ },
29
+ required: ['url', 'mode'],
30
+ additionalProperties: false
31
+ }
32
+ };
33
+ export function createWebToolsExtensions(input) {
34
+ const tools = input.enabled
35
+ ? [
36
+ ...((input.hostedSearchModelIds !== undefined || !input.hostedWebSearch) && input.webSearch
37
+ ? [webSearchToolDefinition]
38
+ : []),
39
+ ...(input.webFetch ? [webFetchToolDefinition] : [])
40
+ ]
41
+ : [];
42
+ const isToolAvailable = (name, context) => tools.some((tool) => tool.name === name) &&
43
+ (name !== 'web_search' ||
44
+ !(context.modelId === undefined || input.hostedSearchModelIds === undefined
45
+ ? input.hostedWebSearch
46
+ : input.hostedSearchModelIds.includes(context.modelId)));
47
+ return {
48
+ tools,
49
+ isToolAvailable,
50
+ executeTool: async (call, context) => {
51
+ if (!isToolAvailable(call.name, context))
52
+ throw new MarAgentError('MAR_AGENT_TOOL_NOT_AVAILABLE', 'Node web tool is unavailable.');
53
+ context.signal.throwIfAborted();
54
+ const parsed = call.name === 'web_search'
55
+ ? webSearchRequestSchema.safeParse(call.arguments)
56
+ : webFetchRequestSchema.safeParse(call.arguments);
57
+ if (!parsed.success)
58
+ throw new MarAgentError('MAR_AGENT_TOOL_INPUT_INVALID', 'Node web tool input is invalid.');
59
+ try {
60
+ if (call.name === 'web_search')
61
+ return await input.webSearch(webSearchRequestSchema.parse(parsed.data), context.signal);
62
+ const request = webFetchRequestSchema.parse(parsed.data);
63
+ return await input.webFetch({ ...request, url: new URL(request.url).href }, context.signal);
64
+ }
65
+ catch (error) {
66
+ if (context.signal.aborted || error instanceof MarAgentError)
67
+ throw error;
68
+ throw new MarAgentError(call.name === 'web_search' ? 'MAR_AGENT_WEB_SEARCH_FAILED' : 'MAR_AGENT_WEB_FETCH_FAILED', call.name === 'web_search' ? 'Web search failed.' : 'Web fetch failed.', { cause: error });
69
+ }
70
+ }
71
+ };
72
+ }
@@ -0,0 +1,46 @@
1
+ import { z } from 'zod';
2
+ export const WEB_FETCH_MAX_BYTES = 1_000_000;
3
+ export const WEB_FETCH_MAX_URL_LENGTH = 8_192;
4
+ const webUrlSchema = z
5
+ .string()
6
+ .max(WEB_FETCH_MAX_URL_LENGTH)
7
+ .refine((value) => {
8
+ try {
9
+ const url = new URL(value);
10
+ return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ });
16
+ export const webFetchModeSchema = z.enum(['http', 'browser']);
17
+ export const webFetchRequestSchema = z
18
+ .object({
19
+ url: webUrlSchema,
20
+ mode: webFetchModeSchema
21
+ })
22
+ .strict();
23
+ export const webSearchRequestSchema = z
24
+ .object({
25
+ query: z.string().trim().min(1).max(500),
26
+ maxResults: z.number().int().min(1).max(10).default(8)
27
+ })
28
+ .strict();
29
+ export const webFetchResultSchema = z
30
+ .object({
31
+ content: z
32
+ .string()
33
+ .max(WEB_FETCH_MAX_BYTES)
34
+ .refine((value) => new TextEncoder().encode(value).byteLength <= WEB_FETCH_MAX_BYTES),
35
+ data: z
36
+ .object({
37
+ url: webUrlSchema,
38
+ mode: webFetchModeSchema,
39
+ status: z.number().int().min(200).max(299),
40
+ contentType: z.string().max(256),
41
+ title: z.string().max(1_000).optional()
42
+ })
43
+ .strict(),
44
+ truncated: z.boolean()
45
+ })
46
+ .strict();
@@ -0,0 +1,345 @@
1
+ import { request as httpRequest } from 'node:http';
2
+ import { request as httpsRequest } from 'node:https';
3
+ import { createBrotliDecompress, createGunzip, createInflate } from 'node:zlib';
4
+ import { pipeline } from 'node:stream/promises';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import { MarAgentError } from '@myagentroam/agent';
7
+ import { chromium, errors } from 'playwright';
8
+ import { WEB_FETCH_MAX_BYTES, webFetchRequestSchema } from './web-fetch-contracts.js';
9
+ const TIMEOUT_MS = 30_000;
10
+ const MAX_REDIRECTS = 5;
11
+ const MAX_BROWSER_REQUESTS = 2;
12
+ export class WebFetchError extends MarAgentError {
13
+ code;
14
+ status;
15
+ publicMessage;
16
+ publicParameters;
17
+ constructor(code, status, publicMessage, publicParameters) {
18
+ super(code, publicMessage, { details: { status, ...(publicParameters ?? {}) } });
19
+ this.code = code;
20
+ this.status = status;
21
+ this.publicMessage = publicMessage;
22
+ this.publicParameters = publicParameters;
23
+ }
24
+ }
25
+ /** Owns Node-side network access and the lifetime of isolated browser reads. */
26
+ export class WebFetchService {
27
+ #shutdown = new AbortController();
28
+ #active = new Set();
29
+ #allowUntrustedTls;
30
+ #launchBrowser;
31
+ #browserRequests = 0;
32
+ #closing;
33
+ constructor(options = {}) {
34
+ this.#allowUntrustedTls =
35
+ options.allowUntrustedTls ?? process.env['MAR_AGENT_WEB_FETCH_ALLOW_UNTRUSTED_TLS'] !== '0';
36
+ this.#launchBrowser = options.launchBrowser ?? chromium.launch.bind(chromium);
37
+ }
38
+ fetch(input, signal, policy = {}) {
39
+ const parsed = webFetchRequestSchema.safeParse(input);
40
+ if (!parsed.success)
41
+ return Promise.reject(new WebFetchError('MAR_AGENT_TOOL_INPUT_INVALID', 400, 'Web fetch input is invalid.'));
42
+ const timeout = AbortSignal.timeout(TIMEOUT_MS);
43
+ const combined = AbortSignal.any([signal, this.#shutdown.signal, timeout]);
44
+ const task = this.#execute(parsed.data, combined, policy.allowUntrustedTls ?? this.#allowUntrustedTls).catch((error) => {
45
+ if (timeout.aborted || error instanceof errors.TimeoutError)
46
+ throw new WebFetchError('MAR_AGENT_WEB_FETCH_TIMEOUT', 504, 'Web fetch timed out.');
47
+ if (combined.aborted)
48
+ throw new WebFetchError('MAR_AGENT_WEB_FETCH_CANCELLED', 503, 'Web fetch was cancelled.');
49
+ if (error instanceof WebFetchError)
50
+ throw error;
51
+ throw new WebFetchError('MAR_AGENT_WEB_FETCH_UPSTREAM_FAILED', 502, 'Web fetch upstream failed.');
52
+ });
53
+ this.#active.add(task);
54
+ void task.then(() => this.#active.delete(task), () => this.#active.delete(task));
55
+ return task;
56
+ }
57
+ close() {
58
+ return (this.#closing ??= this.#close());
59
+ }
60
+ async #close() {
61
+ this.#shutdown.abort();
62
+ await Promise.allSettled([...this.#active]);
63
+ }
64
+ async #execute(input, signal, allowUntrustedTls) {
65
+ signal.throwIfAborted();
66
+ return input.mode === 'http'
67
+ ? this.#fetchHttp(input, signal, allowUntrustedTls)
68
+ : this.#fetchBrowser(input, signal, allowUntrustedTls);
69
+ }
70
+ async #fetchHttp(input, signal, allowUntrustedTls) {
71
+ let url = new URL(input.url);
72
+ for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
73
+ const response = await requestHttp(url, signal, allowUntrustedTls);
74
+ const status = response.statusCode ?? 0;
75
+ const location = response.headers.location;
76
+ if ([301, 302, 303, 307, 308].includes(status) && location) {
77
+ response.destroy();
78
+ const next = new URL(location, url);
79
+ if (!webFetchRequestSchema.safeParse({ url: next.href, mode: 'http' }).success)
80
+ throw new WebFetchError('MAR_AGENT_TOOL_INPUT_INVALID', 400, 'Redirect URL is not supported.');
81
+ url = next;
82
+ continue;
83
+ }
84
+ const contentType = (response.headers['content-type'] ?? 'text/plain').slice(0, 256);
85
+ if (status < 200 || status >= 300 || !readableContentType(contentType)) {
86
+ response.destroy();
87
+ validateResponse(status, contentType);
88
+ }
89
+ const encoding = response.headers['content-encoding']?.trim().toLowerCase() ?? 'identity';
90
+ const decompressor = encoding === 'gzip'
91
+ ? createGunzip()
92
+ : encoding === 'br'
93
+ ? createBrotliDecompress()
94
+ : encoding === 'deflate'
95
+ ? createInflate()
96
+ : undefined;
97
+ if (!decompressor && encoding !== 'identity' && encoding !== '') {
98
+ response.destroy();
99
+ throw new WebFetchError('MAR_AGENT_WEB_FETCH_UNSUPPORTED_CONTENT', 415, 'Response content encoding is not supported.');
100
+ }
101
+ const body = decompressor ?? response;
102
+ let transferError;
103
+ const transfer = decompressor
104
+ ? pipeline(response, decompressor, { signal }).catch((error) => {
105
+ transferError = error;
106
+ })
107
+ : undefined;
108
+ const chunks = [];
109
+ let bytes = 0;
110
+ let truncated = false;
111
+ try {
112
+ for await (const chunk of body) {
113
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
114
+ const remaining = WEB_FETCH_MAX_BYTES - bytes;
115
+ chunks.push(buffer.subarray(0, remaining));
116
+ bytes += Math.min(remaining, buffer.byteLength);
117
+ if (buffer.byteLength > remaining) {
118
+ truncated = true;
119
+ break;
120
+ }
121
+ }
122
+ }
123
+ finally {
124
+ response.destroy();
125
+ decompressor?.destroy();
126
+ await transfer;
127
+ }
128
+ if (!truncated && transferError)
129
+ throw transferError;
130
+ signal.throwIfAborted();
131
+ const text = new TextDecoder().decode(Buffer.concat(chunks), { stream: truncated });
132
+ const output = Buffer.from(text);
133
+ truncated ||= output.byteLength > WEB_FETCH_MAX_BYTES;
134
+ const content = new TextDecoder().decode(output.subarray(0, WEB_FETCH_MAX_BYTES), {
135
+ stream: truncated
136
+ });
137
+ return { content, data: { url: url.href, mode: 'http', status, contentType }, truncated };
138
+ }
139
+ throw new WebFetchError('MAR_AGENT_WEB_FETCH_UPSTREAM_FAILED', 502, 'Too many web fetch redirects.');
140
+ }
141
+ async #fetchBrowser(input, signal, allowUntrustedTls) {
142
+ if (this.#browserRequests >= MAX_BROWSER_REQUESTS)
143
+ throw new WebFetchError('MAR_AGENT_WEB_FETCH_BUSY', 429, 'Node web fetch browser capacity is exhausted.');
144
+ this.#browserRequests++;
145
+ let browser;
146
+ let context;
147
+ let navigationFailure;
148
+ const cancel = () => {
149
+ void browser?.close().catch(() => undefined);
150
+ };
151
+ signal.addEventListener('abort', cancel, { once: true });
152
+ try {
153
+ try {
154
+ browser = await this.#launchBrowser({
155
+ headless: true,
156
+ channel: 'chromium',
157
+ timeout: 15_000
158
+ });
159
+ }
160
+ catch {
161
+ throw new WebFetchError('MAR_AGENT_WEB_FETCH_BROWSER_UNAVAILABLE', 503, 'Node Chromium could not start. Prepare the matching runtime with the Node browser installation script under the service account. On Linux, check Chromium system libraries; an administrator may need to install them.');
162
+ }
163
+ signal.throwIfAborted();
164
+ context = await browser.newContext({
165
+ acceptDownloads: false,
166
+ ignoreHTTPSErrors: allowUntrustedTls,
167
+ serviceWorkers: 'block'
168
+ });
169
+ let blockedNavigation = false;
170
+ let resourceRequests = 0;
171
+ await context.route('**/*', async (route) => {
172
+ const request = route.request();
173
+ resourceRequests++;
174
+ const valid = webFetchRequestSchema.safeParse({
175
+ url: request.url(),
176
+ mode: 'browser'
177
+ }).success;
178
+ if (request.isNavigationRequest() && request.frame() === page.mainFrame() && !valid)
179
+ blockedNavigation = true;
180
+ try {
181
+ if (!valid ||
182
+ blockedNavigation ||
183
+ resourceRequests > 128 ||
184
+ ['image', 'media', 'font'].includes(request.resourceType()))
185
+ await route.abort();
186
+ else
187
+ await route.continue();
188
+ }
189
+ catch {
190
+ // A cancelled or closed page may invalidate an outstanding route.
191
+ }
192
+ });
193
+ const page = await context.newPage();
194
+ // Playwright routes see only the first request in an HTTP redirect chain.
195
+ // Chromium interception validates every hop before any redirected request is sent.
196
+ const cdp = await context.newCDPSession(page);
197
+ const { frameTree } = await cdp.send('Page.getFrameTree');
198
+ const mainFrameId = frameTree.frame.id;
199
+ let navigationRequests = 0;
200
+ let interceptedRequests = 0;
201
+ cdp.on('Fetch.requestPaused', (event) => {
202
+ const valid = webFetchRequestSchema.safeParse({
203
+ url: event.request.url,
204
+ mode: 'browser'
205
+ }).success;
206
+ const mainNavigation = event.resourceType === 'Document' && event.frameId === mainFrameId;
207
+ if (mainNavigation) {
208
+ navigationRequests++;
209
+ if (!valid)
210
+ navigationFailure = new WebFetchError('MAR_AGENT_TOOL_INPUT_INVALID', 400, 'Page navigation URL is not supported.');
211
+ else if (navigationRequests > MAX_REDIRECTS + 1)
212
+ navigationFailure = new WebFetchError('MAR_AGENT_WEB_FETCH_UPSTREAM_FAILED', 502, 'Too many web fetch redirects.');
213
+ }
214
+ const blocked = !valid || navigationFailure !== undefined || ++interceptedRequests > 128;
215
+ void cdp
216
+ .send(blocked ? 'Fetch.failRequest' : 'Fetch.continueRequest', {
217
+ requestId: event.requestId,
218
+ ...(blocked ? { errorReason: 'BlockedByClient' } : {})
219
+ })
220
+ .catch(() => {
221
+ if (!signal.aborted && !page.isClosed()) {
222
+ navigationFailure ??= new WebFetchError('MAR_AGENT_WEB_FETCH_UPSTREAM_FAILED', 502, 'Browser request interception failed.');
223
+ cancel();
224
+ }
225
+ });
226
+ });
227
+ await cdp.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] });
228
+ let navigationResponse;
229
+ page.on('response', (response) => {
230
+ if (response.request().isNavigationRequest() &&
231
+ response.request().frame() === page.mainFrame())
232
+ navigationResponse = response;
233
+ });
234
+ context.on('page', (other) => {
235
+ if (other !== page)
236
+ void other.close().catch(() => undefined);
237
+ });
238
+ page.on('dialog', (dialog) => {
239
+ void dialog.dismiss().catch(() => undefined);
240
+ });
241
+ const response = await page.goto(input.url, {
242
+ waitUntil: 'domcontentloaded',
243
+ timeout: 25_000
244
+ });
245
+ if (!response)
246
+ throw new WebFetchError('MAR_AGENT_WEB_FETCH_UPSTREAM_FAILED', 502, 'Page navigation returned no response.');
247
+ navigationResponse ??= response;
248
+ validateResponse(response.status(), (await response.allHeaders())['content-type'] ?? 'text/html');
249
+ // Wait for visible text to settle, not for long-lived network connections to become idle.
250
+ const start = Date.now();
251
+ let previous = '';
252
+ let changedAt = start;
253
+ let snapshot;
254
+ while (Date.now() - start < 5_000) {
255
+ signal.throwIfAborted();
256
+ try {
257
+ const current = await page.evaluate((maxBytes) => {
258
+ const text = document.body?.innerText.trim() ?? '';
259
+ const bytes = new TextEncoder().encode(text);
260
+ return {
261
+ url: location.href,
262
+ title: document.title.slice(0, 1_000),
263
+ content: new TextDecoder().decode(bytes.subarray(0, maxBytes), {
264
+ stream: bytes.length > maxBytes
265
+ }),
266
+ truncated: bytes.length > maxBytes
267
+ };
268
+ }, WEB_FETCH_MAX_BYTES);
269
+ snapshot = current;
270
+ }
271
+ catch (error) {
272
+ if (page.isClosed() || signal.aborted || navigationFailure)
273
+ throw error;
274
+ // A client-side navigation can replace the evaluation context between snapshots.
275
+ snapshot = undefined;
276
+ }
277
+ const identity = snapshot ? `${snapshot.url}\n${snapshot.content}` : '';
278
+ if (identity !== previous) {
279
+ previous = identity;
280
+ changedAt = Date.now();
281
+ }
282
+ if (snapshot?.content && Date.now() - start >= 1_000 && Date.now() - changedAt >= 750)
283
+ break;
284
+ await delay(100, undefined, { signal });
285
+ }
286
+ signal.throwIfAborted();
287
+ if (navigationFailure)
288
+ throw navigationFailure;
289
+ const finalUrl = page.url();
290
+ if (blockedNavigation ||
291
+ !webFetchRequestSchema.safeParse({ url: finalUrl, mode: 'browser' }).success)
292
+ throw new WebFetchError('MAR_AGENT_TOOL_INPUT_INVALID', 400, 'Page navigation URL is not supported.');
293
+ if (!snapshot?.content || snapshot.url !== finalUrl)
294
+ throw new WebFetchError('MAR_AGENT_WEB_FETCH_EMPTY_CONTENT', 422, 'The rendered page has no readable text.');
295
+ const contentType = ((await navigationResponse.allHeaders())['content-type'] ?? 'text/html').slice(0, 256);
296
+ validateResponse(navigationResponse.status(), contentType);
297
+ return {
298
+ content: snapshot.content,
299
+ data: {
300
+ url: finalUrl,
301
+ mode: 'browser',
302
+ status: navigationResponse.status(),
303
+ contentType,
304
+ title: snapshot.title
305
+ },
306
+ truncated: snapshot.truncated
307
+ };
308
+ }
309
+ catch (error) {
310
+ if (navigationFailure)
311
+ throw navigationFailure;
312
+ throw error;
313
+ }
314
+ finally {
315
+ signal.removeEventListener('abort', cancel);
316
+ await context?.close().catch(() => undefined);
317
+ await browser?.close().catch(() => undefined);
318
+ this.#browserRequests--;
319
+ }
320
+ }
321
+ }
322
+ function readableContentType(contentType) {
323
+ return /^(?:text\/|application\/(?:[\w.+-]+\+)?(?:json|xml)(?:[;\s]|$))/iu.test(contentType);
324
+ }
325
+ function validateResponse(status, contentType) {
326
+ if (status < 200 || status >= 300)
327
+ throw new WebFetchError('MAR_AGENT_WEB_FETCH_UPSTREAM_FAILED', 502, 'Web fetch upstream returned an HTTP error.', { upstreamStatus: status });
328
+ if (!readableContentType(contentType))
329
+ throw new WebFetchError('MAR_AGENT_WEB_FETCH_UNSUPPORTED_CONTENT', 415, 'Response is not readable text.');
330
+ }
331
+ function requestHttp(url, signal, allowUntrustedTls) {
332
+ return new Promise((resolve, reject) => {
333
+ const request = (url.protocol === 'https:' ? httpsRequest : httpRequest)(url, {
334
+ signal,
335
+ headers: {
336
+ accept: 'text/html,application/json,text/plain;q=0.9,*/*;q=0.1',
337
+ 'accept-encoding': 'identity',
338
+ 'user-agent': 'MyAgentRoam/0.9'
339
+ },
340
+ ...(url.protocol === 'https:' ? { rejectUnauthorized: !allowUntrustedTls } : {})
341
+ }, resolve);
342
+ request.once('error', reject);
343
+ request.end();
344
+ });
345
+ }
@@ -0,0 +1,72 @@
1
+ import { MarAgentError } from '@myagentroam/agent';
2
+ import { readWebSearchError } from './web-search-error.js';
3
+ import { WebFetchService } from './web-fetch-service.js';
4
+ import { webFetchResultSchema } from './web-fetch-contracts.js';
5
+ /** Owns Node web reads and the authenticated Server search adapter. */
6
+ export class WebToolService {
7
+ #config;
8
+ #request;
9
+ #fetcher;
10
+ constructor(config, request = fetch) {
11
+ this.#config = config;
12
+ this.#request = request;
13
+ this.#fetcher = new WebFetchService();
14
+ }
15
+ webSearch(input, signal) {
16
+ return this.#execute(input, signal);
17
+ }
18
+ webFetch = async (input, signal, allowUntrustedTls) => {
19
+ const result = await this.#fetcher.fetch(input, signal, {
20
+ ...(allowUntrustedTls === undefined ? {} : { allowUntrustedTls })
21
+ });
22
+ const parsed = webFetchResultSchema.safeParse(result);
23
+ if (!parsed.success)
24
+ throw new MarAgentError('MAR_AGENT_WEB_FETCH_FAILED', 'Web fetch returned an invalid result.');
25
+ return parsed.data;
26
+ };
27
+ close() {
28
+ return this.#fetcher.close();
29
+ }
30
+ async #execute(input, signal) {
31
+ const config = this.#config();
32
+ if (config?.nodeId === undefined || config.credential === undefined)
33
+ throw new Error('NODE_UNAUTHENTICATED');
34
+ try {
35
+ const response = await this.#request(`${config.serverUrl.replace(/\/$/u, '')}/api/node-tools/web-search`, {
36
+ method: 'POST',
37
+ headers: {
38
+ 'content-type': 'application/json',
39
+ 'x-mar-node-id': config.nodeId,
40
+ authorization: `Bearer ${config.credential}`
41
+ },
42
+ body: JSON.stringify(input),
43
+ signal
44
+ });
45
+ if (!response.ok)
46
+ throw await readWebSearchError(response);
47
+ let result;
48
+ try {
49
+ result = await response.json();
50
+ }
51
+ catch {
52
+ throw invalidResult();
53
+ }
54
+ if (result === null || typeof result !== 'object')
55
+ throw invalidResult();
56
+ const content = JSON.stringify(result);
57
+ if (content === undefined)
58
+ throw invalidResult();
59
+ return { content, data: result };
60
+ }
61
+ catch (error) {
62
+ if (error instanceof MarAgentError || signal.aborted)
63
+ throw error;
64
+ throw new MarAgentError('MAR_AGENT_WEB_SEARCH_FAILED', 'Web search failed.', {
65
+ cause: error
66
+ });
67
+ }
68
+ }
69
+ }
70
+ function invalidResult() {
71
+ return new MarAgentError('MAR_AGENT_WEB_SEARCH_FAILED', 'Web search returned an invalid result.');
72
+ }
package/dist/service.js CHANGED
@@ -1,23 +1,32 @@
1
1
  import { chmod, mkdir, rm, writeFile } from 'node:fs/promises';
2
2
  import { dirname, resolve } from 'node:path';
3
- export function systemdUnit(executable, configPath, entrypoint, writablePaths = []) {
3
+ export function browserCachePath(dataDirectory) {
4
+ const configured = process.env['PLAYWRIGHT_BROWSERS_PATH'];
5
+ return configured === '0' ? '0' : resolve(configured ?? resolve(dataDirectory, 'web-browsers'));
6
+ }
7
+ export function systemdUnit(executable, configPath, entrypoint, writablePaths = [], browserPath) {
4
8
  const command = [
5
9
  escapeSystemd(executable),
6
10
  ...(entrypoint === undefined ? [] : [escapeSystemd(entrypoint)])
7
11
  ].join(' ');
8
- const readWritePaths = writablePaths.map((path) => escapeSystemd(resolve(path))).join(' ');
9
- return `[Unit]\nDescription=MyAgentRoam Node\nAfter=network-online.target\n\n[Service]\nType=simple\nUser=mar-node\nNoNewPrivileges=true\nPrivateTmp=true\nProtectSystem=strict\n${readWritePaths.length === 0 ? '' : `ReadWritePaths=${readWritePaths}\n`}ExecStart=${command} supervise --config ${escapeSystemd(configPath)}\nRestart=on-failure\n\n[Install]\nWantedBy=multi-user.target\n`;
12
+ const readWritePaths = [
13
+ ...writablePaths,
14
+ ...(browserPath === undefined || browserPath === '0' ? [] : [browserPath])
15
+ ]
16
+ .map((path) => escapeSystemd(resolve(path)))
17
+ .join(' ');
18
+ return `[Unit]\nDescription=MyAgentRoam Node\nAfter=network-online.target\n\n[Service]\nType=simple\nUser=mar-node\nNoNewPrivileges=true\nPrivateTmp=true\nProtectSystem=strict\n${readWritePaths.length === 0 ? '' : `ReadWritePaths=${readWritePaths}\n`}${browserPath === undefined ? '' : `Environment=${escapeSystemd(`PLAYWRIGHT_BROWSERS_PATH=${browserPath}`)}\n`}ExecStart=${command} supervise --config ${escapeSystemd(configPath)}\nRestart=on-failure\n\n[Install]\nWantedBy=multi-user.target\n`;
10
19
  }
11
- export function windowsServiceDefinition(executable, configPath, entrypoint) {
20
+ export function windowsServiceDefinition(executable, configPath, entrypoint, browserPath) {
12
21
  return JSON.stringify({
13
22
  serviceName: 'MyAgentRoamNode',
14
23
  displayName: 'MyAgentRoam Node',
15
- binaryPath: `"${executable}"${entrypoint === undefined ? '' : ` "${entrypoint}"`} supervise --config "${configPath}"`,
24
+ binaryPath: `"${executable}"${entrypoint === undefined ? '' : ` "${entrypoint}"`} supervise --config "${configPath}"${browserPath === undefined ? '' : ` --browser-cache "${browserPath}"`}`,
16
25
  startType: 'auto',
17
26
  account: 'LocalService'
18
27
  }, undefined, 2);
19
28
  }
20
- export function launchdServiceDefinition(executable, configPath, entrypoint) {
29
+ export function launchdServiceDefinition(executable, configPath, entrypoint, browserPath) {
21
30
  const argumentsList = [
22
31
  executable,
23
32
  ...(entrypoint === undefined ? [] : [entrypoint]),
@@ -41,6 +50,7 @@ ${argumentsList}
41
50
  <dict>
42
51
  <key>PATH</key>
43
52
  <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
53
+ ${browserPath === undefined ? '' : ` <key>PLAYWRIGHT_BROWSERS_PATH</key>\n <string>${escapeXml(browserPath)}</string>`}
44
54
  </dict>
45
55
  <key>RunAtLoad</key>
46
56
  <true/>
@@ -50,13 +60,13 @@ ${argumentsList}
50
60
  </plist>
51
61
  `;
52
62
  }
53
- export async function writeServiceDefinition(outputPath, executable, configPath, platform = process.platform, entrypoint, writablePaths = []) {
63
+ export async function writeServiceDefinition(outputPath, executable, configPath, platform = process.platform, entrypoint, writablePaths = [], browserPath) {
54
64
  const contents = platform === 'linux'
55
- ? systemdUnit(executable, configPath, entrypoint, writablePaths)
65
+ ? systemdUnit(executable, configPath, entrypoint, writablePaths, browserPath)
56
66
  : platform === 'win32'
57
- ? windowsServiceDefinition(executable, configPath, entrypoint)
67
+ ? windowsServiceDefinition(executable, configPath, entrypoint, browserPath)
58
68
  : platform === 'darwin'
59
- ? launchdServiceDefinition(executable, configPath, entrypoint)
69
+ ? launchdServiceDefinition(executable, configPath, entrypoint, browserPath)
60
70
  : (() => {
61
71
  throw new Error('NODE_PLATFORM_UNSUPPORTED');
62
72
  })();
@@ -60,6 +60,15 @@ export async function superviseNode(configPath, bootstrapEntrypoint) {
60
60
  }
61
61
  if (rebuildResult.code !== 0)
62
62
  throw new Error('NODE_UPGRADE_NATIVE_REBUILD_FAILED');
63
+ const browserInstall = await runProcess(process.execPath, [
64
+ resolve(prefix, 'node_modules', '@myagentroam', 'node', 'scripts', 'install-web-browser.mjs')
65
+ ], true);
66
+ if (browserInstall.signalled) {
67
+ stopping = true;
68
+ throw new Error('NODE_UPGRADE_INSTALL_INTERRUPTED');
69
+ }
70
+ if (browserInstall.code !== 0)
71
+ throw new Error('NODE_UPGRADE_BROWSER_INSTALL_FAILED');
63
72
  const nextEntrypoint = resolve(prefix, 'node_modules', '@myagentroam', 'node', 'dist', 'main.js');
64
73
  await access(nextEntrypoint);
65
74
  await writeAtomic(activePath, `${nextEntrypoint}\n`);
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.9.152",
3
+ "version": "0.9.153",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
5
  "license": "Apache-2.0 AND GPL-2.0-or-later",
6
6
  "type": "module",
7
7
  "files": [
8
8
  "dist/**/*.js",
9
+ "scripts/install-web-browser.mjs",
9
10
  "vendor/ripgrep/**/*",
10
11
  "vendor/ctags/**/*",
11
12
  "LICENSE",
@@ -32,17 +33,19 @@
32
33
  "node-datachannel": "0.33.4",
33
34
  "node-pty": "1.1.0",
34
35
  "npm": "11.12.1",
36
+ "playwright": "1.62.1",
35
37
  "ws": "^8.21.3",
36
38
  "zod": "4.4.3",
39
+ "@myagentroam/agent": "0.9.153",
37
40
  "@myagentroam/marctl": "0.9.120",
38
- "@myagentroam/protocol": "0.9.152",
39
41
  "@myagentroam/code-navigation": "0.9.129",
40
- "@myagentroam/agent": "0.9.152"
42
+ "@myagentroam/protocol": "0.9.153"
41
43
  },
42
44
  "devDependencies": {
43
45
  "@types/ws": "^8.18.1"
44
46
  },
45
47
  "scripts": {
48
+ "postinstall": "node scripts/install-web-browser.mjs",
46
49
  "build": "node ../scripts/clean-build-output.mjs dist && tsc -p tsconfig.json",
47
50
  "stage:computer-use-runtime": "node ../scripts/stage-computer-use-runtime.mjs",
48
51
  "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit",
@@ -0,0 +1,62 @@
1
+ import { access, stat } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { chromium } from 'playwright';
8
+
9
+ /** Download browser binaries only; operating-system package installation is never implicit. */
10
+ export async function installWebBrowser(options = {}) {
11
+ const environment = options.environment ?? process.env;
12
+ if (['1', 'true'].includes(environment.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD)) return 'skipped';
13
+ const exists =
14
+ options.exists ??
15
+ (async (path) => {
16
+ if (!(await stat(path)).isFile()) throw new Error('Browser executable is not a regular file');
17
+ await access(path, constants.X_OK);
18
+ });
19
+ let cached = false;
20
+ try {
21
+ await exists(chromium.executablePath());
22
+ cached = true;
23
+ } catch {
24
+ // The exact browser revision is absent or unusable.
25
+ }
26
+ const require = createRequire(import.meta.url);
27
+ const cli = resolve(dirname(require.resolve('playwright/package.json')), 'cli.js');
28
+ const run =
29
+ options.run ??
30
+ ((command, args) =>
31
+ spawnSync(command, args, {
32
+ stdio: 'inherit',
33
+ env: environment,
34
+ timeout: 300_000
35
+ }));
36
+ const result = run(process.execPath, [cli, 'install', 'chromium', '--no-shell']);
37
+ if (result.error || result.signal || result.status !== 0)
38
+ throw new Error(
39
+ 'NODE_WEB_BROWSER_INSTALL_FAILED: Chromium download failed. Check network access or preinstall the matching Playwright Chromium runtime.'
40
+ );
41
+ // Register this package with Playwright's cache GC even when binaries already exist.
42
+ try {
43
+ await exists(chromium.executablePath());
44
+ } catch {
45
+ throw new Error(
46
+ 'NODE_WEB_BROWSER_INSTALL_FAILED: Chromium executable is unavailable or not executable after preparation.'
47
+ );
48
+ }
49
+ return cached ? 'cached' : 'installed';
50
+ }
51
+
52
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
53
+ try {
54
+ const outcome = await installWebBrowser();
55
+ process.stdout.write(`Node Chromium preparation: ${outcome}.\n`);
56
+ } catch (error) {
57
+ process.stderr.write(
58
+ `${error instanceof Error ? error.message : 'NODE_WEB_BROWSER_INSTALL_FAILED'}\n`
59
+ );
60
+ process.exitCode = 1;
61
+ }
62
+ }