@agent-vm/agent-vm 0.0.93 → 0.0.94

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 (33) hide show
  1. package/dist/cli/agent-vm-cli-support.d.ts +4 -1
  2. package/dist/cli/agent-vm-cli-support.d.ts.map +1 -1
  3. package/dist/cli/agent-vm-cli-support.js.map +1 -1
  4. package/dist/cli/commands/doctor-definition.d.ts.map +1 -1
  5. package/dist/cli/commands/doctor-definition.js +6 -0
  6. package/dist/cli/commands/doctor-definition.js.map +1 -1
  7. package/dist/cli/controller-operation-commands.d.ts +19 -0
  8. package/dist/cli/controller-operation-commands.d.ts.map +1 -1
  9. package/dist/cli/controller-operation-commands.js +63 -44
  10. package/dist/cli/controller-operation-commands.js.map +1 -1
  11. package/dist/controller/controller-runtime.js +1 -1
  12. package/dist/controller/controller-runtime.js.map +1 -1
  13. package/dist/controller/worker-task-runner.d.ts +6 -0
  14. package/dist/controller/worker-task-runner.d.ts.map +1 -1
  15. package/dist/controller/worker-task-runner.js +13 -4
  16. package/dist/controller/worker-task-runner.js.map +1 -1
  17. package/dist/gateway/mcp-portal-effective-config.d.ts +11 -0
  18. package/dist/gateway/mcp-portal-effective-config.d.ts.map +1 -1
  19. package/dist/gateway/mcp-portal-effective-config.js +27 -8
  20. package/dist/gateway/mcp-portal-effective-config.js.map +1 -1
  21. package/dist/integration-tests/e2e-harness.d.ts +7 -0
  22. package/dist/integration-tests/e2e-harness.d.ts.map +1 -1
  23. package/dist/integration-tests/e2e-harness.js +371 -37
  24. package/dist/integration-tests/e2e-harness.js.map +1 -1
  25. package/dist/integration-tests/e2e-protocol-wait.d.ts +2 -0
  26. package/dist/integration-tests/e2e-protocol-wait.d.ts.map +1 -0
  27. package/dist/integration-tests/e2e-protocol-wait.js +5 -0
  28. package/dist/integration-tests/e2e-protocol-wait.js.map +1 -0
  29. package/dist/integration-tests/e2e-workspace-build-global-setup.d.ts +13 -2
  30. package/dist/integration-tests/e2e-workspace-build-global-setup.d.ts.map +1 -1
  31. package/dist/integration-tests/e2e-workspace-build-global-setup.js +23 -1
  32. package/dist/integration-tests/e2e-workspace-build-global-setup.js.map +1 -1
  33. 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)}`;
123
155
  }
124
- // oxlint-disable-next-line eslint/no-await-in-loop -- readiness polling is sequential
125
- await new Promise((resolve) => setTimeout(resolve, 500));
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);
126
164
  }
127
- throw new Error('Controller did not become ready in time.');
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;
355
+ }
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
+ });
369
+ }
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,41 +517,120 @@ 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
+ }
278
610
  }
279
- async function removeLocalPackageTarballDirectories(tarballPaths) {
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-');
619
+ }
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
  }
291
636
  async function useLocalToolVmMcpPortalPackageTarballs(options) {
@@ -353,7 +698,7 @@ export async function useLocalToolVmMcpPortalPackage(options) {
353
698
  });
354
699
  }
355
700
  finally {
356
- await removeLocalPackageTarballDirectories([
701
+ await removeE2eLocalPackageTarballs([
357
702
  localConfigContractsTarballPath,
358
703
  localSecretManagementTarballPath,
359
704
  localMcpPortalTarballPath,
@@ -573,7 +918,7 @@ export async function useLocalOpenClawGatewayImagePackages(options) {
573
918
  delete gatewayProfile.source;
574
919
  }
575
920
  finally {
576
- await removeLocalPackageTarballDirectories([
921
+ await removeE2eLocalPackageTarballs([
577
922
  localConfigContractsTarballPath,
578
923
  localSecretManagementTarballPath,
579
924
  localGondolinAdapterTarballPath,
@@ -655,7 +1000,7 @@ export async function useLocalOpenClawPluginGatewayImage(options) {
655
1000
  delete gatewayProfile.source;
656
1001
  }
657
1002
  finally {
658
- await removeLocalPackageTarballDirectories([
1003
+ await removeE2eLocalPackageTarballs([
659
1004
  localSecretManagementTarballPath,
660
1005
  localGondolinAdapterTarballPath,
661
1006
  localGatewayInterfaceTarballPath,
@@ -690,21 +1035,10 @@ export async function scaffoldOpenClawE2eProject(options) {
690
1035
  };
691
1036
  }
692
1037
  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',
1038
+ return await packLocalPackageTarball({
1039
+ packageDirectory: path.join(repoRoot, 'packages', 'agent-vm-worker'),
1040
+ packageName: 'agent-vm-worker',
698
1041
  });
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
1042
  }
709
1043
  export async function scaffoldWorkerE2eProject(options) {
710
1044
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), options.prefix));