@agent-vm/agent-vm 0.0.92 → 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 (151) hide show
  1. package/dist/build/managed-image-dockerfile.d.ts +2 -1
  2. package/dist/build/managed-image-dockerfile.d.ts.map +1 -1
  3. package/dist/build/managed-image-dockerfile.js +51 -27
  4. package/dist/build/managed-image-dockerfile.js.map +1 -1
  5. package/dist/cli/agent-vm-cli-support.d.ts +4 -1
  6. package/dist/cli/agent-vm-cli-support.d.ts.map +1 -1
  7. package/dist/cli/agent-vm-cli-support.js.map +1 -1
  8. package/dist/cli/commands/controller-definition.d.ts +42 -42
  9. package/dist/cli/commands/create-app.d.ts +42 -42
  10. package/dist/cli/commands/doctor-definition.d.ts.map +1 -1
  11. package/dist/cli/commands/doctor-definition.js +6 -0
  12. package/dist/cli/commands/doctor-definition.js.map +1 -1
  13. package/dist/cli/controller-operation-commands.d.ts +19 -0
  14. package/dist/cli/controller-operation-commands.d.ts.map +1 -1
  15. package/dist/cli/controller-operation-commands.js +63 -44
  16. package/dist/cli/controller-operation-commands.js.map +1 -1
  17. package/dist/cli/manual-templates.d.ts.map +1 -1
  18. package/dist/cli/manual-templates.js +11 -2
  19. package/dist/cli/manual-templates.js.map +1 -1
  20. package/dist/config/system-config.d.ts +7 -0
  21. package/dist/config/system-config.d.ts.map +1 -1
  22. package/dist/config/system-config.js +35 -0
  23. package/dist/config/system-config.js.map +1 -1
  24. package/dist/controller/controller-runtime-operations.d.ts +1 -0
  25. package/dist/controller/controller-runtime-operations.d.ts.map +1 -1
  26. package/dist/controller/controller-runtime-operations.js +2 -0
  27. package/dist/controller/controller-runtime-operations.js.map +1 -1
  28. package/dist/controller/controller-runtime-types.d.ts +3 -0
  29. package/dist/controller/controller-runtime-types.d.ts.map +1 -1
  30. package/dist/controller/controller-runtime.d.ts +1 -1
  31. package/dist/controller/controller-runtime.d.ts.map +1 -1
  32. package/dist/controller/controller-runtime.js +208 -117
  33. package/dist/controller/controller-runtime.js.map +1 -1
  34. package/dist/controller/health/channel-provider-recovery-observation.d.ts +23 -0
  35. package/dist/controller/health/channel-provider-recovery-observation.d.ts.map +1 -0
  36. package/dist/controller/health/channel-provider-recovery-observation.js +69 -0
  37. package/dist/controller/health/channel-provider-recovery-observation.js.map +1 -0
  38. package/dist/controller/health/durable-health-event-log.d.ts +24 -0
  39. package/dist/controller/health/durable-health-event-log.d.ts.map +1 -0
  40. package/dist/controller/health/durable-health-event-log.js +89 -0
  41. package/dist/controller/health/durable-health-event-log.js.map +1 -0
  42. package/dist/controller/health/gateway-recovery-actions.d.ts +27 -0
  43. package/dist/controller/health/gateway-recovery-actions.d.ts.map +1 -0
  44. package/dist/controller/health/gateway-recovery-actions.js +71 -0
  45. package/dist/controller/health/gateway-recovery-actions.js.map +1 -0
  46. package/dist/controller/health/gateway-service-health-monitor.d.ts +41 -3
  47. package/dist/controller/health/gateway-service-health-monitor.d.ts.map +1 -1
  48. package/dist/controller/health/gateway-service-health-monitor.js +231 -57
  49. package/dist/controller/health/gateway-service-health-monitor.js.map +1 -1
  50. package/dist/controller/health/gateway-vm-recovery-policy.d.ts +20 -0
  51. package/dist/controller/health/gateway-vm-recovery-policy.d.ts.map +1 -1
  52. package/dist/controller/health/gateway-vm-recovery-policy.js +85 -21
  53. package/dist/controller/health/gateway-vm-recovery-policy.js.map +1 -1
  54. package/dist/controller/health/gateway-vm-recovery-runner.d.ts +39 -0
  55. package/dist/controller/health/gateway-vm-recovery-runner.d.ts.map +1 -0
  56. package/dist/controller/health/gateway-vm-recovery-runner.js +251 -0
  57. package/dist/controller/health/gateway-vm-recovery-runner.js.map +1 -0
  58. package/dist/controller/health/health-event-store.d.ts +4 -0
  59. package/dist/controller/health/health-event-store.d.ts.map +1 -1
  60. package/dist/controller/health/health-event-store.js +19 -0
  61. package/dist/controller/health/health-event-store.js.map +1 -1
  62. package/dist/controller/http/controller-health-event-routes.d.ts +6 -0
  63. package/dist/controller/http/controller-health-event-routes.d.ts.map +1 -1
  64. package/dist/controller/http/controller-health-event-routes.js +49 -0
  65. package/dist/controller/http/controller-health-event-routes.js.map +1 -1
  66. package/dist/controller/http/controller-http-routes.d.ts.map +1 -1
  67. package/dist/controller/http/controller-http-routes.js +6 -0
  68. package/dist/controller/http/controller-http-routes.js.map +1 -1
  69. package/dist/controller/leases/lease-manager.d.ts.map +1 -1
  70. package/dist/controller/leases/lease-manager.js +37 -16
  71. package/dist/controller/leases/lease-manager.js.map +1 -1
  72. package/dist/controller/leases/tool-vm-lease-lifecycle.d.ts +44 -0
  73. package/dist/controller/leases/tool-vm-lease-lifecycle.d.ts.map +1 -0
  74. package/dist/controller/leases/tool-vm-lease-lifecycle.js +28 -0
  75. package/dist/controller/leases/tool-vm-lease-lifecycle.js.map +1 -0
  76. package/dist/controller/worker-task-runner.d.ts +6 -0
  77. package/dist/controller/worker-task-runner.d.ts.map +1 -1
  78. package/dist/controller/worker-task-runner.js +13 -4
  79. package/dist/controller/worker-task-runner.js.map +1 -1
  80. package/dist/controller/zone-runtimes/gateway-lifecycle-operation-record.d.ts +37 -0
  81. package/dist/controller/zone-runtimes/gateway-lifecycle-operation-record.d.ts.map +1 -0
  82. package/dist/controller/zone-runtimes/gateway-lifecycle-operation-record.js +133 -0
  83. package/dist/controller/zone-runtimes/gateway-lifecycle-operation-record.js.map +1 -0
  84. package/dist/controller/zone-runtimes/gateway-zone-state-machine.d.ts +101 -0
  85. package/dist/controller/zone-runtimes/gateway-zone-state-machine.d.ts.map +1 -0
  86. package/dist/controller/zone-runtimes/gateway-zone-state-machine.js +143 -0
  87. package/dist/controller/zone-runtimes/gateway-zone-state-machine.js.map +1 -0
  88. package/dist/controller/zone-runtimes/openclaw-zone-runtime.d.ts +8 -1
  89. package/dist/controller/zone-runtimes/openclaw-zone-runtime.d.ts.map +1 -1
  90. package/dist/controller/zone-runtimes/openclaw-zone-runtime.js +621 -65
  91. package/dist/controller/zone-runtimes/openclaw-zone-runtime.js.map +1 -1
  92. package/dist/controller/zone-runtimes/zone-runtime-errors.d.ts +7 -1
  93. package/dist/controller/zone-runtimes/zone-runtime-errors.d.ts.map +1 -1
  94. package/dist/controller/zone-runtimes/zone-runtime-errors.js +5 -1
  95. package/dist/controller/zone-runtimes/zone-runtime-errors.js.map +1 -1
  96. package/dist/controller/zone-runtimes/zone-runtime-registry.d.ts +2 -0
  97. package/dist/controller/zone-runtimes/zone-runtime-registry.d.ts.map +1 -1
  98. package/dist/controller/zone-runtimes/zone-runtime-registry.js +23 -0
  99. package/dist/controller/zone-runtimes/zone-runtime-registry.js.map +1 -1
  100. package/dist/controller/zone-runtimes/zone-runtime-types.d.ts +7 -0
  101. package/dist/controller/zone-runtimes/zone-runtime-types.d.ts.map +1 -1
  102. package/dist/gateway/gateway-ownership-evidence.d.ts +35 -0
  103. package/dist/gateway/gateway-ownership-evidence.d.ts.map +1 -0
  104. package/dist/gateway/gateway-ownership-evidence.js +10 -0
  105. package/dist/gateway/gateway-ownership-evidence.js.map +1 -0
  106. package/dist/gateway/gateway-recovery.d.ts +16 -0
  107. package/dist/gateway/gateway-recovery.d.ts.map +1 -1
  108. package/dist/gateway/gateway-recovery.js +105 -9
  109. package/dist/gateway/gateway-recovery.js.map +1 -1
  110. package/dist/gateway/gateway-zone-orchestrator.d.ts.map +1 -1
  111. package/dist/gateway/gateway-zone-orchestrator.js +50 -39
  112. package/dist/gateway/gateway-zone-orchestrator.js.map +1 -1
  113. package/dist/gateway/mcp-portal-effective-config.d.ts +11 -0
  114. package/dist/gateway/mcp-portal-effective-config.d.ts.map +1 -1
  115. package/dist/gateway/mcp-portal-effective-config.js +27 -8
  116. package/dist/gateway/mcp-portal-effective-config.js.map +1 -1
  117. package/dist/integration-tests/{smoke-harness.d.ts → e2e-harness.d.ts} +52 -37
  118. package/dist/integration-tests/e2e-harness.d.ts.map +1 -0
  119. package/dist/integration-tests/{smoke-harness.js → e2e-harness.js} +483 -131
  120. package/dist/integration-tests/e2e-harness.js.map +1 -0
  121. package/dist/integration-tests/e2e-protocol-wait.d.ts +2 -0
  122. package/dist/integration-tests/e2e-protocol-wait.d.ts.map +1 -0
  123. package/dist/integration-tests/e2e-protocol-wait.js +5 -0
  124. package/dist/integration-tests/e2e-protocol-wait.js.map +1 -0
  125. package/dist/integration-tests/e2e-workspace-build-global-setup.d.ts +27 -0
  126. package/dist/integration-tests/e2e-workspace-build-global-setup.d.ts.map +1 -0
  127. package/dist/integration-tests/e2e-workspace-build-global-setup.js +49 -0
  128. package/dist/integration-tests/e2e-workspace-build-global-setup.js.map +1 -0
  129. package/dist/integration-tests/live-agent-model-roundtrip-deployment.d.ts +11 -0
  130. package/dist/integration-tests/live-agent-model-roundtrip-deployment.d.ts.map +1 -0
  131. package/dist/integration-tests/live-agent-model-roundtrip-deployment.js +48 -0
  132. package/dist/integration-tests/live-agent-model-roundtrip-deployment.js.map +1 -0
  133. package/dist/integration-tests/live-agent-model-roundtrip-gates.d.ts +11 -0
  134. package/dist/integration-tests/live-agent-model-roundtrip-gates.d.ts.map +1 -0
  135. package/dist/integration-tests/live-agent-model-roundtrip-gates.js +21 -0
  136. package/dist/integration-tests/live-agent-model-roundtrip-gates.js.map +1 -0
  137. package/dist/integration-tests/live-vm-e2e-gates.d.ts +2 -0
  138. package/dist/integration-tests/live-vm-e2e-gates.d.ts.map +1 -0
  139. package/dist/integration-tests/live-vm-e2e-gates.js +4 -0
  140. package/dist/integration-tests/live-vm-e2e-gates.js.map +1 -0
  141. package/dist/operations/controller-status.d.ts +5 -0
  142. package/dist/operations/controller-status.d.ts.map +1 -1
  143. package/dist/operations/controller-status.js +42 -0
  144. package/dist/operations/controller-status.js.map +1 -1
  145. package/package.json +11 -11
  146. package/dist/integration-tests/live-integration-gates.d.ts +0 -2
  147. package/dist/integration-tests/live-integration-gates.d.ts.map +0 -1
  148. package/dist/integration-tests/live-integration-gates.js +0 -4
  149. package/dist/integration-tests/live-integration-gates.js.map +0 -1
  150. package/dist/integration-tests/smoke-harness.d.ts.map +0 -1
  151. package/dist/integration-tests/smoke-harness.js.map +0 -1
@@ -1,30 +1,51 @@
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
- import { resolveGondolinMinimumZigVersion } from '@agent-vm/gondolin-adapter';
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
- const smokeTempRootPrefixes = [
20
- 'agent-vm-gateway-smoke-project-',
21
- 'agent-vm-smoke-harness-',
22
- 'openclaw-control-link-smoke-',
23
- 'openclaw-mcp-portal-smoke-',
24
- 'openclaw-subagent-lease-smoke-',
25
- 'openclaw-zone-git-smoke-',
26
- 'worker-loop-smoke-',
26
+ const e2eTempRootPrefixes = [
27
+ 'agent-vm-gateway-e2e-project-',
28
+ 'agent-vm-e2e-harness-',
29
+ 'openclaw-control-link-e2e-',
30
+ 'openclaw-mcp-portal-e2e-',
31
+ 'openclaw-subagent-lease-e2e-',
32
+ 'openclaw-zone-git-e2e-',
33
+ 'worker-loop-e2e-',
27
34
  ];
35
+ function resolveE2eCacheRoot() {
36
+ const configuredCacheRoot = process.env.AGENT_VM_E2E_CACHE_DIR;
37
+ if (configuredCacheRoot !== undefined && configuredCacheRoot.length > 0) {
38
+ return path.resolve(configuredCacheRoot);
39
+ }
40
+ return path.join(os.tmpdir(), 'agent-vm-e2e-cache');
41
+ }
42
+ function isObjectRecord(value) {
43
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
44
+ }
45
+ export function shouldCleanupE2eDockerImages(options = {}) {
46
+ const env = options.env ?? process.env;
47
+ return options.cleanupImages === true || env.AGENT_VM_E2E_CLEAN_IMAGES === '1';
48
+ }
28
49
  export function hasCommand(command) {
29
50
  try {
30
51
  execFileSync('sh', ['-lc', `command -v ${command} >/dev/null`], { stdio: 'ignore' });
@@ -34,13 +55,13 @@ export function hasCommand(command) {
34
55
  return false;
35
56
  }
36
57
  }
37
- export function currentSmokeArchitecture() {
58
+ export function currentE2eArchitecture() {
38
59
  return process.arch === 'arm64' ? 'aarch64' : 'x86_64';
39
60
  }
40
61
  export function qemuCommandForArchitecture(architecture) {
41
62
  return architecture === 'aarch64' ? 'qemu-system-aarch64' : 'qemu-system-x86_64';
42
63
  }
43
- function isOwnedSmokeTempRoot(tempRoot) {
64
+ function isOwnedE2eTempRoot(tempRoot) {
44
65
  const resolvedTempRoot = path.resolve(tempRoot);
45
66
  const resolvedSystemTempRoot = path.resolve(os.tmpdir());
46
67
  if (resolvedTempRoot === resolvedSystemTempRoot ||
@@ -48,15 +69,15 @@ function isOwnedSmokeTempRoot(tempRoot) {
48
69
  return false;
49
70
  }
50
71
  const basename = path.basename(resolvedTempRoot);
51
- return smokeTempRootPrefixes.some((prefix) => basename.startsWith(prefix));
72
+ return e2eTempRootPrefixes.some((prefix) => basename.startsWith(prefix));
52
73
  }
53
- export async function removeSmokeTempRoot(tempRoot) {
54
- if (!isOwnedSmokeTempRoot(tempRoot)) {
74
+ export async function removeE2eTempRoot(tempRoot) {
75
+ if (!isOwnedE2eTempRoot(tempRoot)) {
55
76
  return;
56
77
  }
57
78
  await fs.rm(tempRoot, { force: true, recursive: true });
58
79
  }
59
- export async function canRunGondolinSmoke(options) {
80
+ export async function canRunGondolinE2e(options) {
60
81
  const commandExists = options.commandExists ?? hasCommand;
61
82
  if (!commandExists(qemuCommandForArchitecture(options.architecture)) ||
62
83
  !commandExists('docker')) {
@@ -67,14 +88,14 @@ export async function canRunGondolinSmoke(options) {
67
88
  return (installedZigVersion !== undefined &&
68
89
  isZigVersionAtLeast(installedZigVersion, requiredZigVersion));
69
90
  }
70
- export async function shouldRunWorkerGatewaySmoke(options) {
91
+ export async function shouldRunWorkerGatewayE2e(options) {
71
92
  const env = options.env ?? process.env;
72
- if (env.AGENT_VM_WORKER_SMOKE !== '1' ||
73
- typeof env.OPEN_AI_TEST_KEY !== 'string' ||
74
- env.OPEN_AI_TEST_KEY.length === 0) {
93
+ if (env.AGENT_VM_WORKER_E2E !== '1' ||
94
+ typeof env.AGENT_VM_TEST_OPENAI_API_KEY !== 'string' ||
95
+ env.AGENT_VM_TEST_OPENAI_API_KEY.length === 0) {
75
96
  return false;
76
97
  }
77
- return await canRunGondolinSmoke({
98
+ return await canRunGondolinE2e({
78
99
  architecture: options.architecture,
79
100
  ...(options.commandExists ? { commandExists: options.commandExists } : {}),
80
101
  ...(options.resolveRequiredZigVersion
@@ -83,12 +104,6 @@ export async function shouldRunWorkerGatewaySmoke(options) {
83
104
  ...(options.resolveZigVersion ? { resolveZigVersion: options.resolveZigVersion } : {}),
84
105
  });
85
106
  }
86
- export function rebuildWorkspacePackages(repoRoot) {
87
- execFileSync('pnpm', ['build'], {
88
- cwd: repoRoot,
89
- stdio: 'inherit',
90
- });
91
- }
92
107
  export async function findAvailablePort() {
93
108
  return await new Promise((resolve, reject) => {
94
109
  const server = net.createServer();
@@ -109,57 +124,292 @@ export async function findAvailablePort() {
109
124
  });
110
125
  });
111
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
+ }
112
140
  export async function waitForControllerReady(controllerPort) {
113
- for (let attempt = 0; attempt < 40; attempt += 1) {
114
- // oxlint-disable-next-line eslint/no-await-in-loop -- readiness polling is sequential
115
- const response = await fetch(`http://127.0.0.1:${controllerPort}/controller-status`).catch(() => null);
116
- if (response?.ok) {
117
- 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 };
118
253
  }
119
- // oxlint-disable-next-line eslint/no-await-in-loop -- readiness polling is sequential
120
- await new Promise((resolve) => setTimeout(resolve, 500));
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 };
121
269
  }
122
- throw new Error('Controller did not become ready in time.');
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 };
123
283
  }
124
- export async function findReusableGatewayImageDirectory(currentProjectRoot, gatewayBuildConfigPath) {
125
- const explicitSmokeCacheRoot = process.env.AGENT_VM_SMOKE_CACHE_DIR;
126
- if (!explicitSmokeCacheRoot) {
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
+ });
374
+ }
375
+ export async function findReusableGatewayImageDirectory(currentProjectRoot, gatewayBuildConfigPath, imageProfileName = 'worker') {
376
+ const explicitE2eCacheRoot = process.env.AGENT_VM_E2E_CACHE_DIR;
377
+ if (!explicitE2eCacheRoot) {
127
378
  return null;
128
379
  }
129
380
  const requiredFingerprint = await computeFingerprintFromConfigPath(gatewayBuildConfigPath);
130
- const tempRootEntries = await fs.readdir(explicitSmokeCacheRoot, { withFileTypes: true });
131
- const smokeRunDirectories = tempRootEntries
132
- .filter((entry) => entry.isDirectory() && entry.name.includes('-smoke-'))
133
- .map((entry) => path.join(explicitSmokeCacheRoot, entry.name));
134
- for (const smokeRunDirectory of smokeRunDirectories) {
135
- if (smokeRunDirectory === currentProjectRoot) {
381
+ if (!(await pathExists(explicitE2eCacheRoot))) {
382
+ return null;
383
+ }
384
+ const tempRootEntries = await fs.readdir(explicitE2eCacheRoot, { withFileTypes: true });
385
+ const e2eRunDirectories = tempRootEntries
386
+ .filter((entry) => entry.isDirectory())
387
+ .map((entry) => path.join(explicitE2eCacheRoot, entry.name));
388
+ for (const e2eRunDirectory of e2eRunDirectories) {
389
+ if (e2eRunDirectory === currentProjectRoot) {
136
390
  continue;
137
391
  }
138
- const candidateImageDir = path.join(smokeRunDirectory, 'cache', 'images', 'gateway', requiredFingerprint);
139
- try {
140
- // oxlint-disable-next-line eslint/no-await-in-loop -- intentionally searches cache candidates
141
- await fs.access(path.join(candidateImageDir, 'manifest.json'));
142
- // oxlint-disable-next-line eslint/no-await-in-loop -- intentionally searches cache candidates
143
- await fs.access(path.join(candidateImageDir, 'rootfs.ext4'));
144
- // oxlint-disable-next-line eslint/no-await-in-loop -- intentionally searches cache candidates
145
- await fs.access(path.join(candidateImageDir, 'initramfs.cpio.lz4'));
392
+ const candidateImageDirectories = [
393
+ path.join(e2eRunDirectory, 'gateway-images', imageProfileName, requiredFingerprint),
394
+ path.join(e2eRunDirectory, 'cache', 'gateway-images', imageProfileName, requiredFingerprint),
395
+ ];
396
+ for (const candidateImageDir of candidateImageDirectories) {
146
397
  // oxlint-disable-next-line eslint/no-await-in-loop -- intentionally searches cache candidates
147
- await fs.access(path.join(candidateImageDir, 'vmlinuz-virt'));
148
- return candidateImageDir;
149
- }
150
- catch {
151
- continue;
398
+ if (await hasBuiltImageAssets(candidateImageDir)) {
399
+ return candidateImageDir;
400
+ }
152
401
  }
153
402
  }
154
403
  return null;
155
404
  }
156
405
  export async function seedGatewayImageCacheIfAvailable(options) {
157
- const reusableImageDir = await findReusableGatewayImageDirectory(options.currentProjectRoot, options.gatewayBuildConfigPath);
406
+ const imageProfileName = options.imageProfileName ?? 'worker';
407
+ const reusableImageDir = await findReusableGatewayImageDirectory(options.currentProjectRoot, options.gatewayBuildConfigPath, imageProfileName);
158
408
  if (!reusableImageDir) {
159
409
  return;
160
410
  }
161
411
  const requiredFingerprint = await computeFingerprintFromConfigPath(options.gatewayBuildConfigPath);
162
- const activeImageDir = path.join(options.activeCacheDir, 'images', 'gateway', requiredFingerprint);
412
+ const activeImageDir = path.join(options.activeCacheDir, 'gateway-images', imageProfileName, requiredFingerprint);
163
413
  if (activeImageDir === reusableImageDir) {
164
414
  return;
165
415
  }
@@ -167,6 +417,23 @@ export async function seedGatewayImageCacheIfAvailable(options) {
167
417
  await fs.mkdir(path.dirname(activeImageDir), { recursive: true });
168
418
  await fs.symlink(reusableImageDir, activeImageDir, 'dir');
169
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
+ }
170
437
  export function createSmokeSecretResolver(secrets) {
171
438
  const resolve = async (ref) => {
172
439
  if (ref.source === 'config') {
@@ -208,14 +475,14 @@ function applySmokeEnvironment(secrets) {
208
475
  }
209
476
  };
210
477
  }
211
- export function getOpenClawSmokeZone(systemConfig) {
478
+ export function getOpenClawE2eZone(systemConfig) {
212
479
  const zone = systemConfig.zones[0];
213
480
  if (!zone || zone.gateway.type !== 'openclaw') {
214
481
  throw new Error('Expected smoke system config to contain an OpenClaw zone.');
215
482
  }
216
483
  return { ...zone, gateway: zone.gateway };
217
484
  }
218
- function getWorkerSmokeZone(systemConfig) {
485
+ function getWorkerE2eZone(systemConfig) {
219
486
  const zone = systemConfig.zones[0];
220
487
  if (!zone || zone.gateway.type !== 'worker') {
221
488
  throw new Error('Expected smoke system config to contain a Worker Gateway zone.');
@@ -247,41 +514,123 @@ async function assertLocalPackageFilesExist(props) {
247
514
  }
248
515
  }));
249
516
  }
517
+ export function resolveLocalPackagePackArgs(packDirectory) {
518
+ return ['pack', '--pack-destination', packDirectory, '--config.ignore-scripts=true'];
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
+ }
250
566
  async function packLocalPackageTarball(props) {
251
567
  const packageJsonPath = path.join(props.packageDirectory, 'package.json');
252
568
  await fs.access(packageJsonPath);
253
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
+ }
254
583
  const packDirectory = await fs.mkdtemp(path.join(os.tmpdir(), `${props.packageName}-pack-`));
255
584
  try {
256
- execFileSync('pnpm', ['pack', '--pack-destination', packDirectory], {
585
+ execFileSync('pnpm', [...resolveLocalPackagePackArgs(packDirectory)], {
257
586
  cwd: props.packageDirectory,
258
587
  stdio: 'pipe',
259
588
  });
260
- const packedTarballs = (await fs.readdir(packDirectory)).filter((fileName) => fileName.endsWith('.tgz'));
261
- const [packedTarballName] = packedTarballs;
262
- if (packedTarballName === undefined) {
263
- throw new Error(`Failed to pack local ${props.packageName} tarball for smoke image.`);
264
- }
265
- if (packedTarballs.length > 1) {
266
- throw new Error(`Expected pnpm pack for ${props.packageName} to produce exactly one tarball.`);
267
- }
268
- 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;
269
602
  }
270
603
  catch (error) {
271
604
  await fs.rm(packDirectory, { force: true, recursive: true });
272
605
  throw error;
273
606
  }
607
+ finally {
608
+ await fs.rm(packDirectory, { force: true, recursive: true });
609
+ }
274
610
  }
275
- 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) {
276
621
  await Promise.all(Array.from(new Set(tarballPaths
277
622
  .filter((tarballPath) => tarballPath !== undefined)
278
- .map((tarballPath) => path.dirname(tarballPath)))).map(async (packDirectory) => {
623
+ .map((tarballPath) => path.dirname(tarballPath))))
624
+ .filter(isOwnedLocalPackagePackDirectory)
625
+ .map(async (packDirectory) => {
279
626
  await fs.rm(packDirectory, { force: true, recursive: true });
280
627
  }));
281
628
  }
282
629
  async function copyLocalPackageTarballsToDockerContext(options) {
283
630
  await Promise.all(options.tarballs.map(async (tarball) => {
284
- 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);
285
634
  }));
286
635
  }
287
636
  async function useLocalToolVmMcpPortalPackageTarballs(options) {
@@ -349,7 +698,7 @@ export async function useLocalToolVmMcpPortalPackage(options) {
349
698
  });
350
699
  }
351
700
  finally {
352
- await removeLocalPackageTarballDirectories([
701
+ await removeE2eLocalPackageTarballs([
353
702
  localConfigContractsTarballPath,
354
703
  localSecretManagementTarballPath,
355
704
  localMcpPortalTarballPath,
@@ -359,13 +708,14 @@ export async function useLocalToolVmMcpPortalPackage(options) {
359
708
  function localPackageTarballArchiveName(packageName) {
360
709
  return `${packageName}-local.tgz`;
361
710
  }
362
- async function writeManagedOpenClawSmokeDockerfileBase(options) {
711
+ async function writeManagedOpenClawE2eDockerfileBase(options) {
363
712
  const managedImageRelease = await resolveManagedImageRelease();
364
713
  const result = await generateManagedDockerfile({
365
714
  base: 'openclaw-gateway',
366
715
  imageTargetFamily: 'gateway',
367
716
  imageTargetName: options.profileName,
368
717
  managedImageRelease,
718
+ openClawAgentVmPackageInstallMode: options.openClawAgentVmPackageInstallMode,
369
719
  outputDirectory: options.dockerContextDirectory,
370
720
  requiredOpenClawPackageNames: ['@openclaw/discord'],
371
721
  });
@@ -383,7 +733,7 @@ function mutableJsonRecord(value) {
383
733
  async function runDockerCommand(args) {
384
734
  await execFileAsync('docker', [...args]);
385
735
  }
386
- async function readSmokeDockerImageTag(buildConfigPath) {
736
+ async function readE2eDockerImageTag(buildConfigPath) {
387
737
  let buildConfig;
388
738
  try {
389
739
  buildConfig = mutableJsonRecord(await loadJsonConfigFile(buildConfigPath));
@@ -398,7 +748,7 @@ async function readSmokeDockerImageTag(buildConfigPath) {
398
748
  const imageTag = ociConfig?.image;
399
749
  return typeof imageTag === 'string' && imageTag.length > 0 ? imageTag : null;
400
750
  }
401
- export async function collectSmokeDockerImageTags(systemConfig) {
751
+ export async function collectE2eDockerImageTags(systemConfig) {
402
752
  const imageProfiles = [
403
753
  ...Object.values(systemConfig.imageProfiles.gateways),
404
754
  ...Object.values(systemConfig.imageProfiles.toolVms),
@@ -406,14 +756,14 @@ export async function collectSmokeDockerImageTags(systemConfig) {
406
756
  const imageTags = [];
407
757
  for (const imageProfile of imageProfiles) {
408
758
  // oxlint-disable-next-line eslint/no-await-in-loop -- config files are intentionally read deterministically
409
- const imageTag = await readSmokeDockerImageTag(imageProfile.buildConfig);
759
+ const imageTag = await readE2eDockerImageTag(imageProfile.buildConfig);
410
760
  if (imageTag !== null) {
411
761
  imageTags.push(imageTag);
412
762
  }
413
763
  }
414
764
  return Array.from(new Set(imageTags));
415
765
  }
416
- export async function removeSmokeDockerImages(imageTags, options = {}) {
766
+ export async function removeE2eDockerImages(imageTags, options = {}) {
417
767
  const dockerCommand = options.runDockerCommand ?? runDockerCommand;
418
768
  for (const imageTag of Array.from(new Set(imageTags))) {
419
769
  try {
@@ -427,10 +777,10 @@ export async function removeSmokeDockerImages(imageTags, options = {}) {
427
777
  await dockerCommand(['image', 'rm', '--force', imageTag]);
428
778
  }
429
779
  }
430
- export async function removeSmokeDockerImagesForSystemConfig(systemConfig, options = {}) {
431
- await removeSmokeDockerImages(await collectSmokeDockerImageTags(systemConfig), options);
780
+ export async function removeE2eDockerImagesForSystemConfig(systemConfig, options = {}) {
781
+ await removeE2eDockerImages(await collectE2eDockerImageTags(systemConfig), options);
432
782
  }
433
- function throwIfSmokeHarnessCleanupFailed(errors) {
783
+ function throwIfE2eHarnessCleanupFailed(errors) {
434
784
  if (errors.length === 0) {
435
785
  return;
436
786
  }
@@ -530,9 +880,10 @@ export async function useLocalOpenClawGatewayImagePackages(options) {
530
880
  sourcePath: localOpenClawMcpPortalPluginTarballPath,
531
881
  },
532
882
  ];
533
- const dockerfilePath = await writeManagedOpenClawSmokeDockerfileBase({
883
+ const dockerfilePath = await writeManagedOpenClawE2eDockerfileBase({
534
884
  dockerContextDirectory,
535
885
  profileName: options.profileName,
886
+ openClawAgentVmPackageInstallMode: 'local-overlay',
536
887
  });
537
888
  await copyLocalPackageTarballsToDockerContext({
538
889
  dockerContextDirectory,
@@ -556,7 +907,9 @@ export async function useLocalOpenClawGatewayImagePackages(options) {
556
907
  ' rm -f ' +
557
908
  localPackageTarballs.map((tarball) => `/tmp/${tarball.archiveName}`).join(' '),
558
909
  'RUN package_root="/opt/agent-vm/local-packages/node_modules" && \\',
559
- ' mkdir -p /home/openclaw/.openclaw/extensions && \\',
910
+ ' global_package_root="$(pnpm root -g)" && \\',
911
+ ' mkdir -p "$global_package_root" /home/openclaw/.openclaw/extensions && \\',
912
+ ' ln -sfn "$package_root/@agent-vm" "$global_package_root/@agent-vm" && \\',
560
913
  ' ln -sfn "$package_root/@agent-vm/openclaw-agent-vm-plugin/dist" /home/openclaw/.openclaw/extensions/gondolin && \\',
561
914
  ' ln -sfn "$package_root/@agent-vm/openclaw-mcp-portal-plugin/dist" /home/openclaw/.openclaw/extensions/mcp-portal',
562
915
  '',
@@ -565,7 +918,7 @@ export async function useLocalOpenClawGatewayImagePackages(options) {
565
918
  delete gatewayProfile.source;
566
919
  }
567
920
  finally {
568
- await removeLocalPackageTarballDirectories([
921
+ await removeE2eLocalPackageTarballs([
569
922
  localConfigContractsTarballPath,
570
923
  localSecretManagementTarballPath,
571
924
  localGondolinAdapterTarballPath,
@@ -617,9 +970,10 @@ export async function useLocalOpenClawPluginGatewayImage(options) {
617
970
  sourcePath: localOpenClawAgentVmPluginTarballPath,
618
971
  },
619
972
  ];
620
- const dockerfilePath = await writeManagedOpenClawSmokeDockerfileBase({
973
+ const dockerfilePath = await writeManagedOpenClawE2eDockerfileBase({
621
974
  dockerContextDirectory,
622
975
  profileName: options.profileName,
976
+ openClawAgentVmPackageInstallMode: 'local-overlay',
623
977
  });
624
978
  await copyLocalPackageTarballsToDockerContext({
625
979
  dockerContextDirectory,
@@ -636,7 +990,9 @@ export async function useLocalOpenClawPluginGatewayImage(options) {
636
990
  ' rm -f ' +
637
991
  localPackageTarballs.map((tarball) => `/tmp/${tarball.archiveName}`).join(' '),
638
992
  'RUN package_root="/opt/agent-vm/local-packages/node_modules" && \\',
639
- ' mkdir -p /home/openclaw/.openclaw/extensions && \\',
993
+ ' global_package_root="$(pnpm root -g)" && \\',
994
+ ' mkdir -p "$global_package_root" /home/openclaw/.openclaw/extensions && \\',
995
+ ' ln -sfn "$package_root/@agent-vm" "$global_package_root/@agent-vm" && \\',
640
996
  ' ln -sfn "$package_root/@agent-vm/openclaw-agent-vm-plugin/dist" /home/openclaw/.openclaw/extensions/gondolin',
641
997
  '',
642
998
  ].join('\n'), 'utf8');
@@ -644,7 +1000,7 @@ export async function useLocalOpenClawPluginGatewayImage(options) {
644
1000
  delete gatewayProfile.source;
645
1001
  }
646
1002
  finally {
647
- await removeLocalPackageTarballDirectories([
1003
+ await removeE2eLocalPackageTarballs([
648
1004
  localSecretManagementTarballPath,
649
1005
  localGondolinAdapterTarballPath,
650
1006
  localGatewayInterfaceTarballPath,
@@ -652,7 +1008,7 @@ export async function useLocalOpenClawPluginGatewayImage(options) {
652
1008
  ]);
653
1009
  }
654
1010
  }
655
- export async function scaffoldOpenClawSmokeProject(options) {
1011
+ export async function scaffoldOpenClawE2eProject(options) {
656
1012
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), options.prefix));
657
1013
  const controllerPort = await findAvailablePort();
658
1014
  const gatewayPort = await findAvailablePort();
@@ -667,8 +1023,8 @@ export async function scaffoldOpenClawSmokeProject(options) {
667
1023
  const systemConfig = await loadSystemConfig(path.join(tempRoot, 'config', 'system.json'));
668
1024
  systemConfig.host.controllerPort = controllerPort;
669
1025
  systemConfig.host.projectNamespace = 'claw-tests-zone-git';
670
- systemConfig.cacheDir = path.join(tempRoot, 'cache');
671
- const zone = getOpenClawSmokeZone(systemConfig);
1026
+ systemConfig.cacheDir = path.join(resolveE2eCacheRoot(), 'openclaw');
1027
+ const zone = getOpenClawE2eZone(systemConfig);
672
1028
  zone.gateway.port = gatewayPort;
673
1029
  return {
674
1030
  controllerPort,
@@ -679,23 +1035,12 @@ export async function scaffoldOpenClawSmokeProject(options) {
679
1035
  };
680
1036
  }
681
1037
  export async function prepareLocalWorkerPackageForGatewayImage(repoRoot) {
682
- await fs.mkdir(path.join(repoRoot, 'tmp'), { recursive: true });
683
- const packDirectory = await fs.mkdtemp(path.join(repoRoot, 'tmp', 'agent-vm-worker-pack-'));
684
- execFileSync('pnpm', ['pack', '--pack-destination', packDirectory], {
685
- cwd: path.join(repoRoot, 'packages', 'agent-vm-worker'),
686
- stdio: 'pipe',
1038
+ return await packLocalPackageTarball({
1039
+ packageDirectory: path.join(repoRoot, 'packages', 'agent-vm-worker'),
1040
+ packageName: 'agent-vm-worker',
687
1041
  });
688
- const packedTarballs = (await fs.readdir(packDirectory)).filter((fileName) => fileName.endsWith('.tgz'));
689
- const [packedTarballName] = packedTarballs;
690
- if (packedTarballName === undefined) {
691
- throw new Error('Failed to pack local agent-vm-worker tarball for smoke image.');
692
- }
693
- if (packedTarballs.length > 1) {
694
- throw new Error('Expected pnpm pack to produce exactly one agent-vm-worker tarball.');
695
- }
696
- return path.join(packDirectory, packedTarballName);
697
1042
  }
698
- export async function scaffoldWorkerSmokeProject(options) {
1043
+ export async function scaffoldWorkerE2eProject(options) {
699
1044
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), options.prefix));
700
1045
  const controllerPort = await findAvailablePort();
701
1046
  const gatewayPort = await findAvailablePort();
@@ -709,12 +1054,12 @@ export async function scaffoldWorkerSmokeProject(options) {
709
1054
  const systemConfig = await loadSystemConfig(path.join(tempRoot, 'config', 'system.json'));
710
1055
  systemConfig.host.controllerPort = controllerPort;
711
1056
  systemConfig.host.projectNamespace = 'claw-tests-worker';
712
- systemConfig.cacheDir = path.join(tempRoot, 'cache');
1057
+ systemConfig.cacheDir = path.join(resolveE2eCacheRoot(), 'worker');
713
1058
  systemConfig.host.secretsProvider = {
714
1059
  type: '1password',
715
- tokenSource: { type: 'env', envVar: 'OPEN_AI_TEST_KEY' },
1060
+ tokenSource: { type: 'env', envVar: 'AGENT_VM_TEST_OPENAI_API_KEY' },
716
1061
  };
717
- const zone = getWorkerSmokeZone(systemConfig);
1062
+ const zone = getWorkerE2eZone(systemConfig);
718
1063
  zone.gateway.port = gatewayPort;
719
1064
  return {
720
1065
  controllerPort,
@@ -724,27 +1069,27 @@ export async function scaffoldWorkerSmokeProject(options) {
724
1069
  zone,
725
1070
  };
726
1071
  }
727
- export async function scaffoldGatewaySmokeProject(options) {
1072
+ export async function scaffoldGatewayE2eProject(options) {
728
1073
  if (options.kind === 'openclaw') {
729
- return await scaffoldOpenClawSmokeProject({
1074
+ return await scaffoldOpenClawE2eProject({
730
1075
  architecture: options.architecture,
731
1076
  prefix: options.prefix,
732
1077
  zoneId: options.zoneId,
733
1078
  ...(options.agents ? { agents: options.agents } : {}),
734
1079
  });
735
1080
  }
736
- return await scaffoldWorkerSmokeProject({
1081
+ return await scaffoldWorkerE2eProject({
737
1082
  architecture: options.architecture,
738
1083
  prefix: options.prefix,
739
1084
  zoneId: options.zoneId,
740
1085
  });
741
1086
  }
742
- export async function writeOpenClawMcpPortalSmokeConfigs(options) {
1087
+ export async function writeOpenClawMcpPortalE2eConfigs(options) {
743
1088
  await fs.writeFile(path.join(options.configDir, 'mcp.config.jsonc'), `${JSON.stringify({
744
1089
  $schema: '../../schemas/mcp.schema.json',
745
1090
  providers: {
746
1091
  upstreamMock: {
747
- discovery: { summary: 'Mock upstream MCP server for smoke tests' },
1092
+ discovery: { summary: 'Mock upstream MCP server for e2e tests' },
748
1093
  kind: 'mcp',
749
1094
  namespace: options.namespace,
750
1095
  transport: {
@@ -777,7 +1122,7 @@ export async function writeOpenClawMcpPortalSmokeConfigs(options) {
777
1122
  schemaVersion: 1,
778
1123
  }, null, '\t')}\n`, 'utf8');
779
1124
  }
780
- export async function startSmokeControllerRuntime(options) {
1125
+ export async function startE2eControllerRuntime(options) {
781
1126
  const restoreEnvironment = applySmokeEnvironment(options.secrets);
782
1127
  const secretResolver = createSmokeSecretResolver(options.secrets);
783
1128
  const tempRoot = path.dirname(path.dirname(options.startOptions.systemConfig.systemConfigPath));
@@ -816,7 +1161,7 @@ export async function startSmokeControllerRuntime(options) {
816
1161
  runtime,
817
1162
  systemConfig: options.startOptions.systemConfig,
818
1163
  tempRoot,
819
- close: async () => {
1164
+ close: async (closeOptions = {}) => {
820
1165
  const cleanupErrors = [];
821
1166
  try {
822
1167
  await runtime.close();
@@ -824,14 +1169,19 @@ export async function startSmokeControllerRuntime(options) {
824
1169
  catch (error) {
825
1170
  cleanupErrors.push(error);
826
1171
  }
827
- try {
828
- await removeSmokeDockerImagesForSystemConfig(options.startOptions.systemConfig);
829
- }
830
- catch (error) {
831
- cleanupErrors.push(error);
1172
+ if (shouldCleanupE2eDockerImages({
1173
+ ...closeOptions,
1174
+ env: process.env,
1175
+ })) {
1176
+ try {
1177
+ await removeE2eDockerImagesForSystemConfig(options.startOptions.systemConfig);
1178
+ }
1179
+ catch (error) {
1180
+ cleanupErrors.push(error);
1181
+ }
832
1182
  }
833
1183
  try {
834
- await removeSmokeTempRoot(tempRoot);
1184
+ await removeE2eTempRoot(tempRoot);
835
1185
  }
836
1186
  catch (error) {
837
1187
  cleanupErrors.push(error);
@@ -839,20 +1189,22 @@ export async function startSmokeControllerRuntime(options) {
839
1189
  finally {
840
1190
  restoreEnvironment();
841
1191
  }
842
- throwIfSmokeHarnessCleanupFailed(cleanupErrors);
1192
+ throwIfE2eHarnessCleanupFailed(cleanupErrors);
843
1193
  },
844
1194
  };
845
1195
  }
846
1196
  catch (error) {
847
1197
  const cleanupErrors = [error];
848
- try {
849
- await removeSmokeDockerImagesForSystemConfig(options.startOptions.systemConfig);
850
- }
851
- catch (cleanupError) {
852
- cleanupErrors.push(cleanupError);
1198
+ if (shouldCleanupE2eDockerImages({ env: process.env })) {
1199
+ try {
1200
+ await removeE2eDockerImagesForSystemConfig(options.startOptions.systemConfig);
1201
+ }
1202
+ catch (cleanupError) {
1203
+ cleanupErrors.push(cleanupError);
1204
+ }
853
1205
  }
854
1206
  try {
855
- await removeSmokeTempRoot(tempRoot);
1207
+ await removeE2eTempRoot(tempRoot);
856
1208
  }
857
1209
  catch (cleanupError) {
858
1210
  cleanupErrors.push(cleanupError);
@@ -860,8 +1212,8 @@ export async function startSmokeControllerRuntime(options) {
860
1212
  finally {
861
1213
  restoreEnvironment();
862
1214
  }
863
- throwIfSmokeHarnessCleanupFailed(cleanupErrors);
1215
+ throwIfE2eHarnessCleanupFailed(cleanupErrors);
864
1216
  throw error;
865
1217
  }
866
1218
  }
867
- //# sourceMappingURL=smoke-harness.js.map
1219
+ //# sourceMappingURL=e2e-harness.js.map