@myagentroam/node 0.9.152 → 0.9.154
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 +9 -23
- package/dist/main.js +11 -3
- package/dist/runner/mar-agent/execution-extensions.js +21 -0
- package/dist/runner/mar-agent/hosted-conversation-runtime.js +16 -7
- package/dist/runner/mar-agent/managed-run-controller.js +66 -25
- package/dist/runner/mar-agent/web-tools-extensions.js +72 -0
- package/dist/service/web-fetch-contracts.js +46 -0
- package/dist/service/web-fetch-service.js +345 -0
- package/dist/service/web-tool-service.js +72 -0
- package/dist/service.js +20 -10
- package/dist/supervisor.js +9 -0
- package/package.json +7 -4
- package/scripts/install-web-browser.mjs +62 -0
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
...
|
|
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: [...(
|
|
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
|
|
492
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
684
|
+
const timer = setTimeout(() => {
|
|
685
|
+
if (runtime.idleTimer !== timer)
|
|
686
|
+
return;
|
|
651
687
|
delete runtime.idleTimer;
|
|
652
|
-
|
|
688
|
+
if (!this.#runtimeInUse(runtime))
|
|
689
|
+
void this.#releaseRuntime(runtime, 'idle_expired');
|
|
653
690
|
}, this.#resourceIdleTtlMs);
|
|
654
|
-
runtime.idleTimer
|
|
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
|
|
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 =
|
|
9
|
-
|
|
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
|
})();
|
package/dist/supervisor.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.9.154",
|
|
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.154",
|
|
40
|
+
"@myagentroam/protocol": "0.9.154",
|
|
37
41
|
"@myagentroam/marctl": "0.9.120",
|
|
38
|
-
"@myagentroam/
|
|
39
|
-
"@myagentroam/code-navigation": "0.9.129",
|
|
40
|
-
"@myagentroam/agent": "0.9.152"
|
|
42
|
+
"@myagentroam/code-navigation": "0.9.129"
|
|
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
|
+
}
|