@agent-vm/agent-vm 0.0.93 → 0.0.95

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.
Files changed (107) hide show
  1. package/dist/cli/agent-vm-cli-support.d.ts +6 -2
  2. package/dist/cli/agent-vm-cli-support.d.ts.map +1 -1
  3. package/dist/cli/agent-vm-cli-support.js +2 -1
  4. package/dist/cli/agent-vm-cli-support.js.map +1 -1
  5. package/dist/cli/commands/controller-definition.d.ts +78 -0
  6. package/dist/cli/commands/controller-definition.d.ts.map +1 -1
  7. package/dist/cli/commands/controller-definition.js +34 -0
  8. package/dist/cli/commands/controller-definition.js.map +1 -1
  9. package/dist/cli/commands/create-app.d.ts +78 -0
  10. package/dist/cli/commands/create-app.d.ts.map +1 -1
  11. package/dist/cli/commands/doctor-definition.d.ts.map +1 -1
  12. package/dist/cli/commands/doctor-definition.js +6 -0
  13. package/dist/cli/commands/doctor-definition.js.map +1 -1
  14. package/dist/cli/controller-operation-commands.d.ts +22 -1
  15. package/dist/cli/controller-operation-commands.d.ts.map +1 -1
  16. package/dist/cli/controller-operation-commands.js +187 -49
  17. package/dist/cli/controller-operation-commands.js.map +1 -1
  18. package/dist/cli/manual-templates.d.ts.map +1 -1
  19. package/dist/cli/manual-templates.js +11 -5
  20. package/dist/cli/manual-templates.js.map +1 -1
  21. package/dist/config/system-config.d.ts +0 -3
  22. package/dist/config/system-config.d.ts.map +1 -1
  23. package/dist/config/system-config.js +1 -7
  24. package/dist/config/system-config.js.map +1 -1
  25. package/dist/controller/controller-runtime-operations.d.ts +9 -1
  26. package/dist/controller/controller-runtime-operations.d.ts.map +1 -1
  27. package/dist/controller/controller-runtime-operations.js +1 -0
  28. package/dist/controller/controller-runtime-operations.js.map +1 -1
  29. package/dist/controller/controller-runtime-types.d.ts +2 -1
  30. package/dist/controller/controller-runtime-types.d.ts.map +1 -1
  31. package/dist/controller/controller-runtime.d.ts.map +1 -1
  32. package/dist/controller/controller-runtime.js +37 -9
  33. package/dist/controller/controller-runtime.js.map +1 -1
  34. package/dist/controller/health/channel-provider-recovery-observation.d.ts.map +1 -1
  35. package/dist/controller/health/channel-provider-recovery-observation.js +4 -1
  36. package/dist/controller/health/channel-provider-recovery-observation.js.map +1 -1
  37. package/dist/controller/health/gateway-vm-recovery-policy.d.ts +2 -1
  38. package/dist/controller/health/gateway-vm-recovery-policy.d.ts.map +1 -1
  39. package/dist/controller/health/gateway-vm-recovery-policy.js +11 -1
  40. package/dist/controller/health/gateway-vm-recovery-policy.js.map +1 -1
  41. package/dist/controller/health/gateway-vm-recovery-runner.d.ts.map +1 -1
  42. package/dist/controller/health/gateway-vm-recovery-runner.js +20 -4
  43. package/dist/controller/health/gateway-vm-recovery-runner.js.map +1 -1
  44. package/dist/controller/http/controller-client.d.ts +3 -0
  45. package/dist/controller/http/controller-client.d.ts.map +1 -1
  46. package/dist/controller/http/controller-client.js +12 -0
  47. package/dist/controller/http/controller-client.js.map +1 -1
  48. package/dist/controller/http/controller-http-route-support.d.ts +3 -0
  49. package/dist/controller/http/controller-http-route-support.d.ts.map +1 -1
  50. package/dist/controller/http/controller-http-route-support.js.map +1 -1
  51. package/dist/controller/http/controller-zone-operation-routes.d.ts.map +1 -1
  52. package/dist/controller/http/controller-zone-operation-routes.js +12 -0
  53. package/dist/controller/http/controller-zone-operation-routes.js.map +1 -1
  54. package/dist/controller/worker-task-runner.d.ts +6 -0
  55. package/dist/controller/worker-task-runner.d.ts.map +1 -1
  56. package/dist/controller/worker-task-runner.js +13 -4
  57. package/dist/controller/worker-task-runner.js.map +1 -1
  58. package/dist/controller/zone-runtimes/gateway-zone-state-machine.d.ts.map +1 -1
  59. package/dist/controller/zone-runtimes/gateway-zone-state-machine.js +3 -0
  60. package/dist/controller/zone-runtimes/gateway-zone-state-machine.js.map +1 -1
  61. package/dist/controller/zone-runtimes/openclaw-zone-runtime.d.ts +8 -1
  62. package/dist/controller/zone-runtimes/openclaw-zone-runtime.d.ts.map +1 -1
  63. package/dist/controller/zone-runtimes/openclaw-zone-runtime.js +150 -34
  64. package/dist/controller/zone-runtimes/openclaw-zone-runtime.js.map +1 -1
  65. package/dist/controller/zone-runtimes/zone-runtime-types.d.ts +8 -0
  66. package/dist/controller/zone-runtimes/zone-runtime-types.d.ts.map +1 -1
  67. package/dist/gateway/credential-manager.d.ts +1 -1
  68. package/dist/gateway/credential-manager.d.ts.map +1 -1
  69. package/dist/gateway/credential-manager.js +3 -18
  70. package/dist/gateway/credential-manager.js.map +1 -1
  71. package/dist/gateway/gateway-recovery.d.ts +7 -2
  72. package/dist/gateway/gateway-recovery.d.ts.map +1 -1
  73. package/dist/gateway/gateway-recovery.js +73 -0
  74. package/dist/gateway/gateway-recovery.js.map +1 -1
  75. package/dist/gateway/gateway-zone-orchestrator.d.ts +8 -1
  76. package/dist/gateway/gateway-zone-orchestrator.d.ts.map +1 -1
  77. package/dist/gateway/gateway-zone-orchestrator.js +216 -84
  78. package/dist/gateway/gateway-zone-orchestrator.js.map +1 -1
  79. package/dist/gateway/gateway-zone-support.d.ts +1 -0
  80. package/dist/gateway/gateway-zone-support.d.ts.map +1 -1
  81. package/dist/gateway/gateway-zone-support.js.map +1 -1
  82. package/dist/gateway/mcp-portal-effective-config.d.ts +13 -0
  83. package/dist/gateway/mcp-portal-effective-config.d.ts.map +1 -1
  84. package/dist/gateway/mcp-portal-effective-config.js +67 -16
  85. package/dist/gateway/mcp-portal-effective-config.js.map +1 -1
  86. package/dist/integration-tests/e2e-harness.d.ts +7 -0
  87. package/dist/integration-tests/e2e-harness.d.ts.map +1 -1
  88. package/dist/integration-tests/e2e-harness.js +463 -105
  89. package/dist/integration-tests/e2e-harness.js.map +1 -1
  90. package/dist/integration-tests/e2e-protocol-wait.d.ts +2 -0
  91. package/dist/integration-tests/e2e-protocol-wait.d.ts.map +1 -0
  92. package/dist/integration-tests/e2e-protocol-wait.js +5 -0
  93. package/dist/integration-tests/e2e-protocol-wait.js.map +1 -0
  94. package/dist/integration-tests/e2e-workspace-build-global-setup.d.ts +13 -2
  95. package/dist/integration-tests/e2e-workspace-build-global-setup.d.ts.map +1 -1
  96. package/dist/integration-tests/e2e-workspace-build-global-setup.js +23 -1
  97. package/dist/integration-tests/e2e-workspace-build-global-setup.js.map +1 -1
  98. package/dist/operations/config-validation.js +1 -1
  99. package/dist/operations/doctor.d.ts.map +1 -1
  100. package/dist/operations/doctor.js +1 -6
  101. package/dist/operations/doctor.js.map +1 -1
  102. package/dist/operations/zone-git-doctor.d.ts +1 -0
  103. package/dist/operations/zone-git-doctor.d.ts.map +1 -1
  104. package/dist/operations/zone-git-doctor.js +3 -1
  105. package/dist/operations/zone-git-doctor.js.map +1 -1
  106. package/managed-images.json +1 -1
  107. package/package.json +11 -11
@@ -1,19 +1,26 @@
1
1
  import { execFile, execFileSync } from 'node:child_process';
2
+ import crypto from 'node:crypto';
2
3
  import fs from 'node:fs/promises';
3
4
  import net from 'node:net';
4
5
  import os from 'node:os';
5
6
  import path from 'node:path';
7
+ import { setTimeout as waitForRetryInterval } from 'node:timers/promises';
6
8
  import { promisify } from 'node:util';
7
9
  import { hasBuiltImageAssets, resolveGondolinMinimumZigVersion } from '@agent-vm/gondolin-adapter';
8
10
  import { computeFingerprintFromConfigPath } from '../build/gondolin-image-builder.js';
9
11
  import { generateManagedDockerfile, resolveManagedImageRelease, } from '../build/managed-image-dockerfile.js';
12
+ import { readPreparedGondolinImage, writePreparedGondolinImage, } from '../build/prepared-gondolin-image-cache.js';
10
13
  import { isZigVersionAtLeast, resolveHostZigVersion } from '../build/zig-compatibility.js';
14
+ import { runBuildCommand } from '../cli/build-command.js';
11
15
  import { scaffoldAgentVmProject } from '../cli/init-command.js';
12
16
  import { loadJsonConfigFile } from '../config/json-config-file.js';
13
17
  import { loadSystemConfig } from '../config/system-config.js';
14
18
  import { startControllerRuntime } from '../controller/controller-runtime.js';
15
19
  import { startGatewayZone } from '../gateway/gateway-zone-orchestrator.js';
16
20
  const defaultOpenClawMcpPortalExtensionsPath = '/home/openclaw/.openclaw/extensions/mcp-portal';
21
+ const dockerContextLocalPackageTimestamp = new Date('2000-01-01T00:00:00.000Z');
22
+ const e2ePreparedImageManifestFileName = 'prepared-e2e-images.json';
23
+ const e2ePreparedImageManifestSchemaVersion = 1;
17
24
  const execFileAsync = promisify(execFile);
18
25
  const openClawMcpPortalPluginName = 'mcp-portal';
19
26
  const e2eTempRootPrefixes = [
@@ -32,6 +39,9 @@ function resolveE2eCacheRoot() {
32
39
  }
33
40
  return path.join(os.tmpdir(), 'agent-vm-e2e-cache');
34
41
  }
42
+ function isObjectRecord(value) {
43
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
44
+ }
35
45
  export function shouldCleanupE2eDockerImages(options = {}) {
36
46
  const env = options.env ?? process.env;
37
47
  return options.cleanupImages === true || env.AGENT_VM_E2E_CLEAN_IMAGES === '1';
@@ -114,17 +124,253 @@ export async function findAvailablePort() {
114
124
  });
115
125
  });
116
126
  }
127
+ function readNodeNetworkErrorCode(error) {
128
+ if (!(error instanceof TypeError) || error.message !== 'fetch failed') {
129
+ return null;
130
+ }
131
+ const cause = error.cause;
132
+ if (typeof cause !== 'object' || cause === null || !('code' in cause)) {
133
+ return null;
134
+ }
135
+ return typeof cause.code === 'string' ? cause.code : null;
136
+ }
137
+ function isRecoverableControllerReadyError(error) {
138
+ return ['ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH'].includes(readNodeNetworkErrorCode(error) ?? '');
139
+ }
117
140
  export async function waitForControllerReady(controllerPort) {
118
- for (let attempt = 0; attempt < 40; attempt += 1) {
119
- // oxlint-disable-next-line eslint/no-await-in-loop -- readiness polling is sequential
120
- const response = await fetch(`http://127.0.0.1:${controllerPort}/controller-status`).catch(() => null);
121
- if (response?.ok) {
122
- return;
141
+ const timeoutMs = 5_000;
142
+ const retryIntervalMs = 50;
143
+ const startedAtMs = performance.now();
144
+ let lastError = 'not attempted';
145
+ while (performance.now() - startedAtMs <= timeoutMs) {
146
+ try {
147
+ // oxlint-disable-next-line no-await-in-loop -- controller startup readiness must observe sequential protocol state.
148
+ const response = await fetch(`http://127.0.0.1:${String(controllerPort)}/controller-status`, {
149
+ signal: AbortSignal.timeout(1_000),
150
+ });
151
+ if (response.ok) {
152
+ return;
153
+ }
154
+ lastError = `HTTP ${String(response.status)}`;
155
+ }
156
+ catch (error) {
157
+ if (!isRecoverableControllerReadyError(error)) {
158
+ throw error;
159
+ }
160
+ lastError = error instanceof Error ? error.message : String(error);
161
+ }
162
+ // oxlint-disable-next-line no-await-in-loop -- controller readiness has no event source from the subprocess boundary.
163
+ await waitForRetryInterval(retryIntervalMs);
164
+ }
165
+ throw new Error(`Controller did not report ready within ${String(timeoutMs)}ms. Last error: ${lastError}`);
166
+ }
167
+ async function pathExists(filePath) {
168
+ try {
169
+ await fs.access(filePath);
170
+ return true;
171
+ }
172
+ catch (error) {
173
+ if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
174
+ return false;
175
+ }
176
+ throw error;
177
+ }
178
+ }
179
+ async function listDirectoryFiles(directoryPath) {
180
+ const entries = await fs.readdir(directoryPath, { withFileTypes: true });
181
+ const files = await Promise.all(entries.map(async (entry) => {
182
+ const entryPath = path.join(directoryPath, entry.name);
183
+ if (entry.isDirectory()) {
184
+ return await listDirectoryFiles(entryPath);
185
+ }
186
+ return entry.isFile() ? [entryPath] : [];
187
+ }));
188
+ return files.flat().toSorted((leftPath, rightPath) => leftPath.localeCompare(rightPath));
189
+ }
190
+ async function hashFileInto(hasher, filePath, baseDirectory) {
191
+ const relativePath = path.relative(baseDirectory, filePath).split(path.sep).join('/');
192
+ hasher.update(`file:${relativePath}\0`);
193
+ hasher.update(await fs.readFile(filePath));
194
+ hasher.update('\0');
195
+ }
196
+ async function computeDockerContextFingerprint(dockerfilePath) {
197
+ const dockerContextDirectory = path.dirname(dockerfilePath);
198
+ const hasher = crypto.createHash('sha256');
199
+ for (const filePath of await listDirectoryFiles(dockerContextDirectory)) {
200
+ // oxlint-disable-next-line no-await-in-loop -- hash order is deterministic and low-volume for e2e Docker contexts.
201
+ await hashFileInto(hasher, filePath, dockerContextDirectory);
202
+ }
203
+ return hasher.digest('hex');
204
+ }
205
+ async function readTextIfPresent(filePath) {
206
+ if (filePath === undefined || !(await pathExists(filePath))) {
207
+ return undefined;
208
+ }
209
+ return await fs.readFile(filePath, 'utf8');
210
+ }
211
+ async function normalizeE2eImageSourceForFingerprint(source) {
212
+ if (!isObjectRecord(source)) {
213
+ return source;
214
+ }
215
+ const overlay = source.overlay;
216
+ return {
217
+ ...source,
218
+ ...(typeof overlay === 'string'
219
+ ? { overlay: { content: await readTextIfPresent(overlay) } }
220
+ : {}),
221
+ };
222
+ }
223
+ async function computeE2eImageRecipeFingerprint(options) {
224
+ const hasher = crypto.createHash('sha256');
225
+ hasher.update(JSON.stringify({
226
+ buildConfig: await readTextIfPresent(options.buildConfigPath),
227
+ dockerContext: options.dockerfile === undefined
228
+ ? undefined
229
+ : await computeDockerContextFingerprint(options.dockerfile),
230
+ source: await normalizeE2eImageSourceForFingerprint(options.source),
231
+ }));
232
+ return hasher.digest('hex');
233
+ }
234
+ async function collectE2eImageTargets(project) {
235
+ const createImageTarget = async (family, profileName, profile) => {
236
+ const target = {
237
+ buildConfigPath: profile.buildConfig,
238
+ cacheDirectory: path.join(project.systemConfig.cacheDir, family === 'gateway' ? 'gateway-images' : 'tool-vm-images', profileName),
239
+ e2eManifestEligible: profile.source === undefined,
240
+ family,
241
+ name: profileName,
242
+ recipeFingerprint: await computeE2eImageRecipeFingerprint({
243
+ buildConfigPath: profile.buildConfig,
244
+ ...(profile.dockerfile === undefined ? {} : { dockerfile: profile.dockerfile }),
245
+ ...(profile.source === undefined ? {} : { source: profile.source }),
246
+ }),
247
+ };
248
+ if (profile.dockerfile !== undefined) {
249
+ return { ...target, dockerfile: profile.dockerfile };
250
+ }
251
+ if (profile.source !== undefined) {
252
+ return { ...target, source: profile.source };
253
+ }
254
+ return target;
255
+ };
256
+ const gatewayTargets = await Promise.all(Object.entries(project.systemConfig.imageProfiles.gateways).map(async ([profileName, profile]) => await createImageTarget('gateway', profileName, profile)));
257
+ const toolVmTargets = await Promise.all(Object.entries(project.systemConfig.imageProfiles.toolVms).map(async ([profileName, profile]) => await createImageTarget('toolVm', profileName, profile)));
258
+ return [...gatewayTargets, ...toolVmTargets];
259
+ }
260
+ function e2ePreparedImageManifestPath(cacheDir) {
261
+ return path.join(cacheDir, e2ePreparedImageManifestFileName);
262
+ }
263
+ function parseE2ePreparedImageManifest(value) {
264
+ if (!isObjectRecord(value) || value.schemaVersion !== e2ePreparedImageManifestSchemaVersion) {
265
+ return { entries: [], schemaVersion: e2ePreparedImageManifestSchemaVersion };
266
+ }
267
+ if (!Array.isArray(value.entries)) {
268
+ return { entries: [], schemaVersion: e2ePreparedImageManifestSchemaVersion };
269
+ }
270
+ const entries = value.entries.filter((entry) => {
271
+ if (!isObjectRecord(entry)) {
272
+ return false;
273
+ }
274
+ return ((entry.family === 'gateway' || entry.family === 'toolVm') &&
275
+ typeof entry.name === 'string' &&
276
+ typeof entry.recipeFingerprint === 'string' &&
277
+ typeof entry.buildConfigPath === 'string' &&
278
+ typeof entry.cacheDirectory === 'string' &&
279
+ typeof entry.fingerprint === 'string' &&
280
+ typeof entry.imagePath === 'string');
281
+ });
282
+ return { entries, schemaVersion: e2ePreparedImageManifestSchemaVersion };
283
+ }
284
+ async function readE2ePreparedImageManifest(cacheDir) {
285
+ try {
286
+ const parsed = JSON.parse(await fs.readFile(e2ePreparedImageManifestPath(cacheDir), 'utf8'));
287
+ return parseE2ePreparedImageManifest(parsed);
288
+ }
289
+ catch (error) {
290
+ if (error instanceof SyntaxError) {
291
+ return { entries: [], schemaVersion: e2ePreparedImageManifestSchemaVersion };
292
+ }
293
+ if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
294
+ return { entries: [], schemaVersion: e2ePreparedImageManifestSchemaVersion };
295
+ }
296
+ throw error;
297
+ }
298
+ }
299
+ async function writeE2ePreparedImageManifest(cacheDir, manifest) {
300
+ await fs.mkdir(cacheDir, { recursive: true });
301
+ await fs.writeFile(e2ePreparedImageManifestPath(cacheDir), `${JSON.stringify(manifest, null, '\t')}\n`, 'utf8');
302
+ }
303
+ function e2eImageTargetKey(target) {
304
+ return `${target.family}:${target.name}:${target.recipeFingerprint}`;
305
+ }
306
+ function e2eManifestEntryKey(entry) {
307
+ return `${entry.family}:${entry.name}:${entry.recipeFingerprint}`;
308
+ }
309
+ async function materializePreparedE2eImagesFromManifest(project, targets) {
310
+ if (targets.some((target) => !target.e2eManifestEligible)) {
311
+ return false;
312
+ }
313
+ const manifest = await readE2ePreparedImageManifest(project.systemConfig.cacheDir);
314
+ const entriesByKey = new Map(manifest.entries.map((entry) => [e2eManifestEntryKey(entry), entry]));
315
+ const targetReadiness = await Promise.all(targets.map(async (target) => {
316
+ const entry = entriesByKey.get(e2eImageTargetKey(target));
317
+ return entry !== undefined && (await hasBuiltImageAssets(entry.imagePath));
318
+ }));
319
+ if (targetReadiness.some((isReady) => !isReady)) {
320
+ return false;
321
+ }
322
+ await Promise.all(targets.map(async (target) => {
323
+ const entry = entriesByKey.get(e2eImageTargetKey(target));
324
+ if (entry === undefined) {
325
+ throw new Error(`Missing prepared e2e image manifest entry for ${target.family}/${target.name}.`);
326
+ }
327
+ await writePreparedGondolinImage({
328
+ buildConfigPath: target.buildConfigPath,
329
+ cacheDir: target.cacheDirectory,
330
+ fingerprint: entry.fingerprint,
331
+ ...(entry.fingerprintInput === undefined
332
+ ? {}
333
+ : { fingerprintInput: entry.fingerprintInput }),
334
+ imagePath: entry.imagePath,
335
+ });
336
+ }));
337
+ return true;
338
+ }
339
+ async function recordPreparedE2eImages(project, targets) {
340
+ const manifest = await readE2ePreparedImageManifest(project.systemConfig.cacheDir);
341
+ const entriesByKey = new Map(manifest.entries.map((entry) => [e2eManifestEntryKey(entry), entry]));
342
+ const preparedImages = await Promise.all(targets.map(async (target) => {
343
+ if (!target.e2eManifestEligible) {
344
+ return null;
345
+ }
346
+ const preparedImage = await readPreparedGondolinImage({
347
+ buildConfigPath: target.buildConfigPath,
348
+ cacheDir: target.cacheDirectory,
349
+ });
350
+ return { preparedImage, target };
351
+ }));
352
+ for (const item of preparedImages) {
353
+ if (item === null || item.preparedImage === undefined) {
354
+ continue;
123
355
  }
124
- // oxlint-disable-next-line eslint/no-await-in-loop -- readiness polling is sequential
125
- await new Promise((resolve) => setTimeout(resolve, 500));
356
+ const { preparedImage, target } = item;
357
+ entriesByKey.set(e2eImageTargetKey(target), {
358
+ buildConfigPath: path.resolve(target.buildConfigPath),
359
+ cacheDirectory: path.resolve(target.cacheDirectory),
360
+ family: target.family,
361
+ fingerprint: preparedImage.fingerprint,
362
+ ...(preparedImage.fingerprintInput === undefined
363
+ ? {}
364
+ : { fingerprintInput: preparedImage.fingerprintInput }),
365
+ imagePath: preparedImage.imagePath,
366
+ name: target.name,
367
+ recipeFingerprint: target.recipeFingerprint,
368
+ });
126
369
  }
127
- throw new Error('Controller did not become ready in time.');
370
+ await writeE2ePreparedImageManifest(project.systemConfig.cacheDir, {
371
+ entries: [...entriesByKey.values()].toSorted((leftEntry, rightEntry) => e2eManifestEntryKey(leftEntry).localeCompare(e2eManifestEntryKey(rightEntry))),
372
+ schemaVersion: e2ePreparedImageManifestSchemaVersion,
373
+ });
128
374
  }
129
375
  export async function findReusableGatewayImageDirectory(currentProjectRoot, gatewayBuildConfigPath, imageProfileName = 'worker') {
130
376
  const explicitE2eCacheRoot = process.env.AGENT_VM_E2E_CACHE_DIR;
@@ -132,6 +378,9 @@ export async function findReusableGatewayImageDirectory(currentProjectRoot, gate
132
378
  return null;
133
379
  }
134
380
  const requiredFingerprint = await computeFingerprintFromConfigPath(gatewayBuildConfigPath);
381
+ if (!(await pathExists(explicitE2eCacheRoot))) {
382
+ return null;
383
+ }
135
384
  const tempRootEntries = await fs.readdir(explicitE2eCacheRoot, { withFileTypes: true });
136
385
  const e2eRunDirectories = tempRootEntries
137
386
  .filter((entry) => entry.isDirectory())
@@ -168,6 +417,23 @@ export async function seedGatewayImageCacheIfAvailable(options) {
168
417
  await fs.mkdir(path.dirname(activeImageDir), { recursive: true });
169
418
  await fs.symlink(reusableImageDir, activeImageDir, 'dir');
170
419
  }
420
+ export async function prepareGatewayE2eProjectImages(options) {
421
+ const imageTargets = await collectE2eImageTargets(options.project);
422
+ if (await materializePreparedE2eImagesFromManifest(options.project, imageTargets)) {
423
+ return;
424
+ }
425
+ await Promise.all(Object.entries(options.project.systemConfig.imageProfiles.gateways).map(async ([profileName, gatewayProfile]) => {
426
+ await seedGatewayImageCacheIfAvailable({
427
+ activeCacheDir: options.project.systemConfig.cacheDir,
428
+ currentProjectRoot: options.project.tempRoot,
429
+ gatewayBuildConfigPath: gatewayProfile.buildConfig,
430
+ imageProfileName: profileName,
431
+ });
432
+ }));
433
+ const runBuild = options.runBuild ?? runBuildCommand;
434
+ await runBuild({ systemConfig: options.project.systemConfig });
435
+ await recordPreparedE2eImages(options.project, imageTargets);
436
+ }
171
437
  export function createSmokeSecretResolver(secrets) {
172
438
  const resolve = async (ref) => {
173
439
  if (ref.source === 'config') {
@@ -251,43 +517,162 @@ async function assertLocalPackageFilesExist(props) {
251
517
  export function resolveLocalPackagePackArgs(packDirectory) {
252
518
  return ['pack', '--pack-destination', packDirectory, '--config.ignore-scripts=true'];
253
519
  }
520
+ function parseLocalPackagePackPlan(value, packageName) {
521
+ if (!isJsonRecord(value)) {
522
+ throw new Error(`Expected pnpm pack dry-run for ${packageName} to return an object.`);
523
+ }
524
+ const files = Array.isArray(value.files)
525
+ ? value.files.filter((file) => {
526
+ return isJsonRecord(file) && typeof file.path === 'string' && file.path.length > 0;
527
+ })
528
+ : [];
529
+ if (typeof value.name !== 'string' ||
530
+ typeof value.version !== 'string' ||
531
+ typeof value.filename !== 'string' ||
532
+ files.length === 0) {
533
+ throw new Error(`Unexpected pnpm pack dry-run result for ${packageName}.`);
534
+ }
535
+ return {
536
+ filename: value.filename,
537
+ files,
538
+ name: value.name,
539
+ version: value.version,
540
+ };
541
+ }
542
+ function resolveLocalPackagePackPlan(packageDirectory, packageName) {
543
+ const dryRunOutput = execFileSync('pnpm', ['pack', '--dry-run', '--json', '--config.ignore-scripts=true'], {
544
+ cwd: packageDirectory,
545
+ encoding: 'utf8',
546
+ stdio: ['ignore', 'pipe', 'pipe'],
547
+ });
548
+ return parseLocalPackagePackPlan(JSON.parse(dryRunOutput), packageName);
549
+ }
550
+ function cacheSafeLocalPackageName(packageName) {
551
+ return packageName.replace(/^@/u, '').replaceAll('/', '__');
552
+ }
553
+ async function computeLocalPackagePackFingerprint(packageDirectory, packPlan) {
554
+ const hash = crypto.createHash('sha256');
555
+ hash.update(`${packPlan.name}\0${packPlan.version}\0${packPlan.filename}\0`);
556
+ for (const file of packPlan.files.toSorted((left, right) => left.path.localeCompare(right.path))) {
557
+ const filePath = path.join(packageDirectory, file.path);
558
+ // oxlint-disable-next-line no-await-in-loop -- ordered hashing keeps cache keys deterministic
559
+ const fileContents = await fs.readFile(filePath);
560
+ hash.update(`${file.path}\0`);
561
+ hash.update(fileContents);
562
+ hash.update('\0');
563
+ }
564
+ return hash.digest('hex').slice(0, 24);
565
+ }
254
566
  async function packLocalPackageTarball(props) {
255
567
  const packageJsonPath = path.join(props.packageDirectory, 'package.json');
256
568
  await fs.access(packageJsonPath);
257
569
  await assertLocalPackageFilesExist(props);
570
+ const packPlan = resolveLocalPackagePackPlan(props.packageDirectory, props.packageName);
571
+ const packFingerprint = await computeLocalPackagePackFingerprint(props.packageDirectory, packPlan);
572
+ const cachedPackageDirectory = path.join(resolveE2eCacheRoot(), 'local-package-tarballs', cacheSafeLocalPackageName(props.packageName), packFingerprint);
573
+ const cachedTarballPath = path.join(cachedPackageDirectory, packPlan.filename);
574
+ try {
575
+ await fs.access(cachedTarballPath);
576
+ return cachedTarballPath;
577
+ }
578
+ catch (error) {
579
+ if (!isJsonRecord(error) || error.code !== 'ENOENT') {
580
+ throw error;
581
+ }
582
+ }
258
583
  const packDirectory = await fs.mkdtemp(path.join(os.tmpdir(), `${props.packageName}-pack-`));
259
584
  try {
260
585
  execFileSync('pnpm', [...resolveLocalPackagePackArgs(packDirectory)], {
261
586
  cwd: props.packageDirectory,
262
587
  stdio: 'pipe',
263
588
  });
264
- const packedTarballs = (await fs.readdir(packDirectory)).filter((fileName) => fileName.endsWith('.tgz'));
265
- const [packedTarballName] = packedTarballs;
266
- if (packedTarballName === undefined) {
267
- throw new Error(`Failed to pack local ${props.packageName} tarball for smoke image.`);
268
- }
269
- if (packedTarballs.length > 1) {
270
- throw new Error(`Expected pnpm pack for ${props.packageName} to produce exactly one tarball.`);
271
- }
272
- return path.join(packDirectory, packedTarballName);
589
+ const packedTarballPath = path.join(packDirectory, packPlan.filename);
590
+ await fs.access(packedTarballPath);
591
+ await fs.mkdir(cachedPackageDirectory, { recursive: true });
592
+ const temporaryCachedTarballPath = path.join(cachedPackageDirectory, `${packPlan.filename}.${process.pid}.${crypto.randomUUID()}.tmp`);
593
+ await fs.copyFile(packedTarballPath, temporaryCachedTarballPath);
594
+ await fs.rename(temporaryCachedTarballPath, cachedTarballPath).catch(async (error) => {
595
+ await fs.rm(temporaryCachedTarballPath, { force: true });
596
+ if (isJsonRecord(error) && error.code === 'EEXIST') {
597
+ return;
598
+ }
599
+ throw error;
600
+ });
601
+ return cachedTarballPath;
273
602
  }
274
603
  catch (error) {
275
604
  await fs.rm(packDirectory, { force: true, recursive: true });
276
605
  throw error;
277
606
  }
607
+ finally {
608
+ await fs.rm(packDirectory, { force: true, recursive: true });
609
+ }
610
+ }
611
+ function isOwnedLocalPackagePackDirectory(packDirectory) {
612
+ const resolvedPackDirectory = path.resolve(packDirectory);
613
+ const resolvedSystemTempRoot = path.resolve(os.tmpdir());
614
+ if (resolvedPackDirectory === resolvedSystemTempRoot ||
615
+ !resolvedPackDirectory.startsWith(`${resolvedSystemTempRoot}${path.sep}`)) {
616
+ return false;
617
+ }
618
+ return path.basename(resolvedPackDirectory).includes('-pack-');
278
619
  }
279
- async function removeLocalPackageTarballDirectories(tarballPaths) {
620
+ export async function removeE2eLocalPackageTarballs(tarballPaths) {
280
621
  await Promise.all(Array.from(new Set(tarballPaths
281
622
  .filter((tarballPath) => tarballPath !== undefined)
282
- .map((tarballPath) => path.dirname(tarballPath)))).map(async (packDirectory) => {
623
+ .map((tarballPath) => path.dirname(tarballPath))))
624
+ .filter(isOwnedLocalPackagePackDirectory)
625
+ .map(async (packDirectory) => {
283
626
  await fs.rm(packDirectory, { force: true, recursive: true });
284
627
  }));
285
628
  }
286
629
  async function copyLocalPackageTarballsToDockerContext(options) {
287
630
  await Promise.all(options.tarballs.map(async (tarball) => {
288
- await fs.copyFile(tarball.sourcePath, path.join(options.dockerContextDirectory, tarball.archiveName));
631
+ const targetPath = path.join(options.dockerContextDirectory, tarball.archiveName);
632
+ await fs.copyFile(tarball.sourcePath, targetPath);
633
+ await fs.utimes(targetPath, dockerContextLocalPackageTimestamp, dockerContextLocalPackageTimestamp);
289
634
  }));
290
635
  }
636
+ function shellSingleQuote(value) {
637
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
638
+ }
639
+ function createLocalDockerPackageTarball(props) {
640
+ return {
641
+ archiveName: path.basename(props.sourcePath),
642
+ packageName: props.packageName,
643
+ sourcePath: props.sourcePath,
644
+ };
645
+ }
646
+ function localDockerPackageDependencyName(tarball) {
647
+ return `@agent-vm/${tarball.packageName}`;
648
+ }
649
+ function renderLocalDockerPackageManifest(tarballs) {
650
+ const dependencies = Object.fromEntries(tarballs.map((tarball) => [
651
+ localDockerPackageDependencyName(tarball),
652
+ `file:/tmp/${tarball.archiveName}`,
653
+ ]));
654
+ return `${JSON.stringify({
655
+ private: true,
656
+ dependencies,
657
+ pnpm: {
658
+ overrides: dependencies,
659
+ },
660
+ }, null, 2)}\n`;
661
+ }
662
+ function renderLocalDockerPackageInstallLines(tarballs) {
663
+ const manifestWriterScript = [
664
+ 'require("node:fs").writeFileSync(',
665
+ '"/opt/agent-vm/local-packages/package.json",',
666
+ JSON.stringify(renderLocalDockerPackageManifest(tarballs)),
667
+ ')',
668
+ ].join('');
669
+ return [
670
+ 'RUN mkdir -p /opt/agent-vm/local-packages && \\',
671
+ ` node -e ${shellSingleQuote(manifestWriterScript)} && \\`,
672
+ ' cd /opt/agent-vm/local-packages && pnpm install --prod --ignore-scripts && \\',
673
+ ' rm -f ' + tarballs.map((tarball) => `/tmp/${tarball.archiveName}`).join(' '),
674
+ ];
675
+ }
291
676
  async function useLocalToolVmMcpPortalPackageTarballs(options) {
292
677
  const managedImageRelease = await resolveManagedImageRelease();
293
678
  const baseImage = managedImageRelease.baseImages['tool-vm'];
@@ -295,35 +680,32 @@ async function useLocalToolVmMcpPortalPackageTarballs(options) {
295
680
  await Promise.all(toolVmProfiles.map(async ([profileName, toolVmProfile]) => {
296
681
  const dockerContextDirectory = path.join(options.projectRoot, 'vm-images', 'tool-vms', `${profileName}-local-mcp-portal`);
297
682
  const dockerfilePath = path.join(dockerContextDirectory, 'Dockerfile');
298
- const localConfigContractsTarballName = 'config-contracts-local.tgz';
299
- const localSecretManagementTarballName = 'secret-management-local.tgz';
300
- const localTarballName = 'mcp-portal-local.tgz';
301
683
  await fs.rm(dockerContextDirectory, { force: true, recursive: true });
302
684
  await fs.mkdir(dockerContextDirectory, { recursive: true });
685
+ const localPackageTarballs = [
686
+ createLocalDockerPackageTarball({
687
+ packageName: 'config-contracts',
688
+ sourcePath: options.localConfigContractsTarballPath,
689
+ }),
690
+ createLocalDockerPackageTarball({
691
+ packageName: 'secret-management',
692
+ sourcePath: options.localSecretManagementTarballPath,
693
+ }),
694
+ createLocalDockerPackageTarball({
695
+ packageName: 'mcp-portal',
696
+ sourcePath: options.localMcpPortalTarballPath,
697
+ }),
698
+ ];
303
699
  await copyLocalPackageTarballsToDockerContext({
304
700
  dockerContextDirectory,
305
- tarballs: [
306
- {
307
- archiveName: localConfigContractsTarballName,
308
- sourcePath: options.localConfigContractsTarballPath,
309
- },
310
- {
311
- archiveName: localSecretManagementTarballName,
312
- sourcePath: options.localSecretManagementTarballPath,
313
- },
314
- { archiveName: localTarballName, sourcePath: options.localMcpPortalTarballPath },
315
- ],
701
+ tarballs: localPackageTarballs,
316
702
  });
317
703
  await fs.writeFile(dockerfilePath, [
318
704
  `FROM ${baseImage.repository}:${baseImage.tag}`,
319
705
  '',
320
706
  '# Generated by the OpenClaw smoke harness from the local MCP Portal package.',
321
- 'COPY config-contracts-local.tgz /tmp/config-contracts-local.tgz',
322
- 'COPY secret-management-local.tgz /tmp/secret-management-local.tgz',
323
- 'COPY mcp-portal-local.tgz /tmp/mcp-portal-local.tgz',
324
- 'RUN mkdir -p /opt/agent-vm/local-packages && \\',
325
- ' npm install --omit=dev --no-audit --no-fund --prefix /opt/agent-vm/local-packages /tmp/config-contracts-local.tgz /tmp/secret-management-local.tgz /tmp/mcp-portal-local.tgz && \\',
326
- ' rm -f /tmp/config-contracts-local.tgz /tmp/secret-management-local.tgz /tmp/mcp-portal-local.tgz',
707
+ ...localPackageTarballs.map((tarball) => `COPY ${tarball.archiveName} /tmp/${tarball.archiveName}`),
708
+ ...renderLocalDockerPackageInstallLines(localPackageTarballs),
327
709
  '',
328
710
  ].join('\n'), 'utf8');
329
711
  toolVmProfile.dockerfile = dockerfilePath;
@@ -353,16 +735,13 @@ export async function useLocalToolVmMcpPortalPackage(options) {
353
735
  });
354
736
  }
355
737
  finally {
356
- await removeLocalPackageTarballDirectories([
738
+ await removeE2eLocalPackageTarballs([
357
739
  localConfigContractsTarballPath,
358
740
  localSecretManagementTarballPath,
359
741
  localMcpPortalTarballPath,
360
742
  ]);
361
743
  }
362
744
  }
363
- function localPackageTarballArchiveName(packageName) {
364
- return `${packageName}-local.tgz`;
365
- }
366
745
  async function writeManagedOpenClawE2eDockerfileBase(options) {
367
746
  const managedImageRelease = await resolveManagedImageRelease();
368
747
  const result = await generateManagedDockerfile({
@@ -506,34 +885,34 @@ export async function useLocalOpenClawGatewayImagePackages(options) {
506
885
  });
507
886
  try {
508
887
  const localPackageTarballs = [
509
- {
510
- archiveName: localPackageTarballArchiveName('config-contracts'),
888
+ createLocalDockerPackageTarball({
889
+ packageName: 'config-contracts',
511
890
  sourcePath: localConfigContractsTarballPath,
512
- },
513
- {
514
- archiveName: localPackageTarballArchiveName('secret-management'),
891
+ }),
892
+ createLocalDockerPackageTarball({
893
+ packageName: 'secret-management',
515
894
  sourcePath: localSecretManagementTarballPath,
516
- },
517
- {
518
- archiveName: localPackageTarballArchiveName('gondolin-adapter'),
895
+ }),
896
+ createLocalDockerPackageTarball({
897
+ packageName: 'gondolin-adapter',
519
898
  sourcePath: localGondolinAdapterTarballPath,
520
- },
521
- {
522
- archiveName: localPackageTarballArchiveName('gateway-interface'),
899
+ }),
900
+ createLocalDockerPackageTarball({
901
+ packageName: 'gateway-interface',
523
902
  sourcePath: localGatewayInterfaceTarballPath,
524
- },
525
- {
526
- archiveName: localPackageTarballArchiveName('mcp-portal'),
903
+ }),
904
+ createLocalDockerPackageTarball({
905
+ packageName: 'mcp-portal',
527
906
  sourcePath: localMcpPortalTarballPath,
528
- },
529
- {
530
- archiveName: localPackageTarballArchiveName('openclaw-agent-vm-plugin'),
907
+ }),
908
+ createLocalDockerPackageTarball({
909
+ packageName: 'openclaw-agent-vm-plugin',
531
910
  sourcePath: localOpenClawAgentVmPluginTarballPath,
532
- },
533
- {
534
- archiveName: localPackageTarballArchiveName('openclaw-mcp-portal-plugin'),
911
+ }),
912
+ createLocalDockerPackageTarball({
913
+ packageName: 'openclaw-mcp-portal-plugin',
535
914
  sourcePath: localOpenClawMcpPortalPluginTarballPath,
536
- },
915
+ }),
537
916
  ];
538
917
  const dockerfilePath = await writeManagedOpenClawE2eDockerfileBase({
539
918
  dockerContextDirectory,
@@ -555,12 +934,7 @@ export async function useLocalOpenClawGatewayImagePackages(options) {
555
934
  '',
556
935
  '# Local package overlay generated by the OpenClaw smoke harness.',
557
936
  ...localPackageTarballs.map((tarball) => `COPY ${tarball.archiveName} /tmp/${tarball.archiveName}`),
558
- 'RUN mkdir -p /opt/agent-vm/local-packages && \\',
559
- ' npm install --omit=dev --no-audit --no-fund --prefix /opt/agent-vm/local-packages ' +
560
- localPackageTarballs.map((tarball) => `/tmp/${tarball.archiveName}`).join(' ') +
561
- ' && \\',
562
- ' rm -f ' +
563
- localPackageTarballs.map((tarball) => `/tmp/${tarball.archiveName}`).join(' '),
937
+ ...renderLocalDockerPackageInstallLines(localPackageTarballs),
564
938
  'RUN package_root="/opt/agent-vm/local-packages/node_modules" && \\',
565
939
  ' global_package_root="$(pnpm root -g)" && \\',
566
940
  ' mkdir -p "$global_package_root" /home/openclaw/.openclaw/extensions && \\',
@@ -573,7 +947,7 @@ export async function useLocalOpenClawGatewayImagePackages(options) {
573
947
  delete gatewayProfile.source;
574
948
  }
575
949
  finally {
576
- await removeLocalPackageTarballDirectories([
950
+ await removeE2eLocalPackageTarballs([
577
951
  localConfigContractsTarballPath,
578
952
  localSecretManagementTarballPath,
579
953
  localGondolinAdapterTarballPath,
@@ -608,22 +982,22 @@ export async function useLocalOpenClawPluginGatewayImage(options) {
608
982
  });
609
983
  try {
610
984
  const localPackageTarballs = [
611
- {
612
- archiveName: localPackageTarballArchiveName('secret-management'),
985
+ createLocalDockerPackageTarball({
986
+ packageName: 'secret-management',
613
987
  sourcePath: localSecretManagementTarballPath,
614
- },
615
- {
616
- archiveName: localPackageTarballArchiveName('gondolin-adapter'),
988
+ }),
989
+ createLocalDockerPackageTarball({
990
+ packageName: 'gondolin-adapter',
617
991
  sourcePath: localGondolinAdapterTarballPath,
618
- },
619
- {
620
- archiveName: localPackageTarballArchiveName('gateway-interface'),
992
+ }),
993
+ createLocalDockerPackageTarball({
994
+ packageName: 'gateway-interface',
621
995
  sourcePath: localGatewayInterfaceTarballPath,
622
- },
623
- {
624
- archiveName: localPackageTarballArchiveName('openclaw-agent-vm-plugin'),
996
+ }),
997
+ createLocalDockerPackageTarball({
998
+ packageName: 'openclaw-agent-vm-plugin',
625
999
  sourcePath: localOpenClawAgentVmPluginTarballPath,
626
- },
1000
+ }),
627
1001
  ];
628
1002
  const dockerfilePath = await writeManagedOpenClawE2eDockerfileBase({
629
1003
  dockerContextDirectory,
@@ -638,12 +1012,7 @@ export async function useLocalOpenClawPluginGatewayImage(options) {
638
1012
  '',
639
1013
  '# Local plugin overlay generated by the OpenClaw smoke harness.',
640
1014
  ...localPackageTarballs.map((tarball) => `COPY ${tarball.archiveName} /tmp/${tarball.archiveName}`),
641
- 'RUN mkdir -p /opt/agent-vm/local-packages && \\',
642
- ' npm install --omit=dev --no-audit --no-fund --prefix /opt/agent-vm/local-packages ' +
643
- localPackageTarballs.map((tarball) => `/tmp/${tarball.archiveName}`).join(' ') +
644
- ' && \\',
645
- ' rm -f ' +
646
- localPackageTarballs.map((tarball) => `/tmp/${tarball.archiveName}`).join(' '),
1015
+ ...renderLocalDockerPackageInstallLines(localPackageTarballs),
647
1016
  'RUN package_root="/opt/agent-vm/local-packages/node_modules" && \\',
648
1017
  ' global_package_root="$(pnpm root -g)" && \\',
649
1018
  ' mkdir -p "$global_package_root" /home/openclaw/.openclaw/extensions && \\',
@@ -655,7 +1024,7 @@ export async function useLocalOpenClawPluginGatewayImage(options) {
655
1024
  delete gatewayProfile.source;
656
1025
  }
657
1026
  finally {
658
- await removeLocalPackageTarballDirectories([
1027
+ await removeE2eLocalPackageTarballs([
659
1028
  localSecretManagementTarballPath,
660
1029
  localGondolinAdapterTarballPath,
661
1030
  localGatewayInterfaceTarballPath,
@@ -690,21 +1059,10 @@ export async function scaffoldOpenClawE2eProject(options) {
690
1059
  };
691
1060
  }
692
1061
  export async function prepareLocalWorkerPackageForGatewayImage(repoRoot) {
693
- await fs.mkdir(path.join(repoRoot, 'tmp'), { recursive: true });
694
- const packDirectory = await fs.mkdtemp(path.join(repoRoot, 'tmp', 'agent-vm-worker-pack-'));
695
- execFileSync('pnpm', ['pack', '--pack-destination', packDirectory], {
696
- cwd: path.join(repoRoot, 'packages', 'agent-vm-worker'),
697
- stdio: 'pipe',
1062
+ return await packLocalPackageTarball({
1063
+ packageDirectory: path.join(repoRoot, 'packages', 'agent-vm-worker'),
1064
+ packageName: 'agent-vm-worker',
698
1065
  });
699
- const packedTarballs = (await fs.readdir(packDirectory)).filter((fileName) => fileName.endsWith('.tgz'));
700
- const [packedTarballName] = packedTarballs;
701
- if (packedTarballName === undefined) {
702
- throw new Error('Failed to pack local agent-vm-worker tarball for smoke image.');
703
- }
704
- if (packedTarballs.length > 1) {
705
- throw new Error('Expected pnpm pack to produce exactly one agent-vm-worker tarball.');
706
- }
707
- return path.join(packDirectory, packedTarballName);
708
1066
  }
709
1067
  export async function scaffoldWorkerE2eProject(options) {
710
1068
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), options.prefix));