neoagent 3.0.1-beta.20 → 3.0.1-beta.21

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.
@@ -0,0 +1,182 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const { spawnSync } = require('child_process');
7
+ const { DATA_DIR } = require('../../../runtime/paths');
8
+ const { stageGuestPayload, normalizeRuntimeProfile } = require('./guest_bootstrap');
9
+
10
+ // Where build contexts are staged. One directory per profile.
11
+ const BUILD_ROOT = path.join(DATA_DIR, 'runtime-vms', 'guest-image');
12
+ // Slim official Node base — Playwright browsers + OS deps are added at build time
13
+ // via `playwright install --with-deps`, so the image is self-contained and the
14
+ // browser version always matches the pinned `playwright-chromium` dependency.
15
+ const BASE_IMAGE = String(process.env.NEOAGENT_GUEST_BASE_IMAGE || 'node:20-bookworm-slim').trim();
16
+ const IMAGE_REPO = 'neoagent-guest-agent';
17
+ const BUILD_TIMEOUT_MS = Number(process.env.NEOAGENT_GUEST_IMAGE_BUILD_TIMEOUT_MS || 20 * 60 * 1000);
18
+
19
+ // The build context is the staged guest payload (package.json + runtime/ + server/).
20
+ // Dependencies and browsers are installed once, at image build time — never per
21
+ // container start. Copy package.json first so the dependency layer stays cached
22
+ // across source-only changes.
23
+ function dockerfileFor(profile) {
24
+ if (normalizeRuntimeProfile(profile) === 'android') {
25
+ return [
26
+ `FROM ${BASE_IMAGE}`,
27
+ 'ENV NODE_ENV=production',
28
+ 'ENV NEOAGENT_GUEST_PROFILE=android',
29
+ 'WORKDIR /opt/neoagent',
30
+ 'COPY package.json ./',
31
+ 'RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force',
32
+ 'COPY runtime ./runtime',
33
+ 'COPY server ./server',
34
+ // World-writable runtime dir so the agent works whether the container runs
35
+ // as root (macOS) or as the host uid:gid (Linux, for shared file ownership).
36
+ 'RUN mkdir -p /opt/neoagent/.runtime && chmod -R 0777 /opt/neoagent/.runtime',
37
+ 'CMD ["node", "server/guest_agent.js"]',
38
+ '',
39
+ ].join('\n');
40
+ }
41
+ return [
42
+ `FROM ${BASE_IMAGE}`,
43
+ 'ENV NODE_ENV=production',
44
+ 'ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright',
45
+ 'ENV NEOAGENT_GUEST_PROFILE=browser_cli',
46
+ 'WORKDIR /opt/neoagent',
47
+ 'COPY package.json ./',
48
+ // Install deps without running package postinstall scripts, then fetch the
49
+ // matching Chromium and its OS dependencies explicitly. This mirrors the
50
+ // proven QEMU guest bootstrap and keeps the browser revision deterministic.
51
+ 'RUN npm install --omit=dev --ignore-scripts --no-audit --no-fund \\',
52
+ ' && npx playwright install --with-deps chromium \\',
53
+ ' && npm cache clean --force',
54
+ 'COPY runtime ./runtime',
55
+ 'COPY server ./server',
56
+ // World-writable runtime dir + browser cache so the agent works whether the
57
+ // container runs as root (macOS) or as the host uid:gid (Linux, for shared
58
+ // file ownership). Chromium needs PLAYWRIGHT_BROWSERS_PATH readable by all.
59
+ 'RUN mkdir -p /opt/neoagent/.runtime && chmod -R 0777 /opt/neoagent/.runtime && chmod -R a+rX /ms-playwright',
60
+ 'CMD ["node", "server/guest_agent.js"]',
61
+ '',
62
+ ].join('\n');
63
+ }
64
+
65
+ // Stable content hash over every staged file plus the Dockerfile, so the image
66
+ // tag changes whenever the guest source, its dependencies, or the base image
67
+ // change. A new tag forces a rebuild; an unchanged tag reuses the cached image.
68
+ function hashBuildContext(contextDir, dockerfile) {
69
+ const hash = crypto.createHash('sha256');
70
+ hash.update(dockerfile);
71
+ const walk = (dir) => {
72
+ for (const name of fs.readdirSync(dir).sort()) {
73
+ const full = path.join(dir, name);
74
+ const stat = fs.statSync(full);
75
+ if (stat.isDirectory()) {
76
+ walk(full);
77
+ } else {
78
+ hash.update(path.relative(contextDir, full));
79
+ hash.update(fs.readFileSync(full));
80
+ }
81
+ }
82
+ };
83
+ walk(contextDir);
84
+ return hash.digest('hex').slice(0, 12);
85
+ }
86
+
87
+ function docker(args, opts = {}) {
88
+ return spawnSync('docker', args, {
89
+ encoding: 'utf8',
90
+ stdio: ['ignore', 'pipe', 'pipe'],
91
+ timeout: opts.timeout || 30000,
92
+ ...opts,
93
+ });
94
+ }
95
+
96
+ function dockerAvailable() {
97
+ const result = docker(['info'], { timeout: 5000 });
98
+ return !result.error && result.status === 0;
99
+ }
100
+
101
+ // Builds and caches the per-profile guest-agent Docker image. The image bakes in
102
+ // the guest agent, its dependencies, and (for browser_cli) the Chromium browser,
103
+ // so containers start the agent directly with no runtime installation step.
104
+ class GuestImageBuilder {
105
+ #buildPromise = null;
106
+ #cachedTag = null;
107
+ #cachedBuiltAt = 0;
108
+
109
+ constructor(options = {}) {
110
+ this.profile = normalizeRuntimeProfile(options.runtimeProfile || 'browser_cli');
111
+ this.contextDir = path.join(BUILD_ROOT, this.profile);
112
+ }
113
+
114
+ // Stage the build context on disk and return its content-addressed image tag.
115
+ prepareContext() {
116
+ const dockerfile = dockerfileFor(this.profile);
117
+ stageGuestPayload(this.contextDir, this.profile);
118
+ fs.writeFileSync(path.join(this.contextDir, 'Dockerfile'), dockerfile);
119
+ fs.writeFileSync(path.join(this.contextDir, '.dockerignore'), 'node_modules\n');
120
+ const tag = `${IMAGE_REPO}:${this.profile}-${hashBuildContext(this.contextDir, dockerfile)}`;
121
+ this.#cachedTag = tag;
122
+ return tag;
123
+ }
124
+
125
+ imageExists(tag) {
126
+ const result = docker(['image', 'inspect', tag], { timeout: 10000 });
127
+ return !result.error && result.status === 0;
128
+ }
129
+
130
+ // Returns { dockerAvailable, imageBuilt, image } without triggering a build.
131
+ // Cheap enough to call from readiness polling (only stages the context once).
132
+ getState() {
133
+ if (!dockerAvailable()) {
134
+ return { dockerAvailable: false, imageBuilt: false, image: this.#cachedTag };
135
+ }
136
+ let tag = this.#cachedTag;
137
+ try {
138
+ if (!tag) tag = this.prepareContext();
139
+ } catch (err) {
140
+ console.warn(`[GuestImage:${this.profile}] Failed to stage build context: ${err.message}`);
141
+ return { dockerAvailable: true, imageBuilt: false, image: null };
142
+ }
143
+ return { dockerAvailable: true, imageBuilt: this.imageExists(tag), image: tag };
144
+ }
145
+
146
+ // Ensure the image exists, building it if necessary. Concurrent callers share
147
+ // the same in-flight build. Returns the resolved image tag.
148
+ async ensure() {
149
+ const tag = this.prepareContext();
150
+ if (this.imageExists(tag)) {
151
+ this.#cachedBuiltAt = Date.now();
152
+ return tag;
153
+ }
154
+ if (this.#buildPromise) return this.#buildPromise;
155
+ this.#buildPromise = this.#build(tag).finally(() => { this.#buildPromise = null; });
156
+ return this.#buildPromise;
157
+ }
158
+
159
+ async #build(tag) {
160
+ console.log(`[GuestImage:${this.profile}] Building guest image ${tag} (one-time; downloads browser + deps)…`);
161
+ const started = Date.now();
162
+ const result = docker(['build', '-t', tag, this.contextDir], {
163
+ timeout: BUILD_TIMEOUT_MS,
164
+ stdio: ['ignore', 'inherit', 'inherit'],
165
+ });
166
+ if (result.error) {
167
+ throw new Error(`Guest image build failed to start: ${result.error.message}`);
168
+ }
169
+ if (result.status !== 0) {
170
+ throw new Error(`Guest image build for ${tag} exited with status ${result.status}`);
171
+ }
172
+ this.#cachedBuiltAt = Date.now();
173
+ console.log(`[GuestImage:${this.profile}] Built ${tag} in ${Math.round((Date.now() - started) / 1000)}s`);
174
+ return tag;
175
+ }
176
+ }
177
+
178
+ module.exports = {
179
+ GuestImageBuilder,
180
+ dockerAvailable,
181
+ BASE_IMAGE,
182
+ };
@@ -19,7 +19,6 @@ class RuntimeManager {
19
19
 
20
20
  const browserVmManager = options.browserVmManager || new DockerVMManager({
21
21
  runtimeProfile: 'browser_cli',
22
- image: 'mcr.microsoft.com/playwright:v1.44.0-focal',
23
22
  memoryMb: DEFAULT_VM_MEMORY_MB,
24
23
  cpus: DEFAULT_VM_CPUS,
25
24
  });
@@ -11,14 +11,9 @@ function getRuntimeValidation(runtimeManager) {
11
11
 
12
12
  if (policy.profile === 'prod' || nodeEnvIsProd) {
13
13
  if (!browserVmReadiness) {
14
- issues.push('prod profile requires a working local VM runtime for browser/CLI.');
15
- } else if (!browserVmReadiness.ready) {
16
- if (!browserVmReadiness.qemuAvailable) {
17
- issues.push(`prod profile requires QEMU (${browserVmReadiness.qemuBinary}) to be installed for browser/CLI.`);
18
- }
19
- if (!browserVmReadiness.baseImageExists && !browserVmReadiness.downloadConfigured) {
20
- issues.push('prod profile requires a VM base image or a downloadable base image URL for browser/CLI.');
21
- }
14
+ issues.push('prod profile requires a working container runtime for browser/CLI.');
15
+ } else if (!browserVmReadiness.dockerAvailable) {
16
+ issues.push('prod profile requires Docker to be installed and running for the browser/CLI runtime.');
22
17
  }
23
18
  }
24
19