@agent-vm/openclaw-agent-vm-plugin 0.0.81 → 0.0.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
- import { createToolVmActiveUseHandle, isToolVmLeasePeek, isToolVmSshLease } from "@agent-vm/gateway-interface";
1
+ import path from "node:path/posix";
2
+ import { ControllerRequestPolicyTransportError, OPENCLAW_STATE_SANDBOXES_VM_ROOT, TOOL_VM_SCRATCH_GUEST_ROOT, TOOL_VM_WORKSPACE_GUEST_ROOT, createToolVmActiveUseHandle, drainControllerResponseBody, fetchControllerWithPolicy, gatewayControlLinkHealthPins, isToolVmLeasePeek, isToolVmSshLease, translateRuntimePath } from "@agent-vm/gateway-interface";
2
3
  import { z } from "zod";
3
4
  //#region src/controller-lease-client.ts
4
5
  var ControllerLeaseRequestError = class extends Error {
@@ -46,7 +47,7 @@ function isHeartbeatActiveUseResponse(value) {
46
47
  const record = objectValue(value);
47
48
  return record !== void 0 && typeof Reflect.get(record, "expiresAt") === "number" && typeof Reflect.get(record, "heartbeatAfterMs") === "number";
48
49
  }
49
- function formatUnknownError$1(error) {
50
+ function formatUnknownError$2(error) {
50
51
  return error instanceof Error ? error.message : String(error);
51
52
  }
52
53
  function writeLeaseClientLog(message) {
@@ -58,7 +59,7 @@ function parseJsonBody(bodyText, context) {
58
59
  const parsedBody = jsonValueSchema.safeParse(parsedJson);
59
60
  return parsedBody.success ? parsedBody.data : void 0;
60
61
  } catch (error) {
61
- writeLeaseClientLog(`${context} returned a non-JSON error body: ${formatUnknownError$1(error)}`);
62
+ writeLeaseClientLog(`${context} returned a non-JSON error body: ${formatUnknownError$2(error)}`);
62
63
  return;
63
64
  }
64
65
  }
@@ -86,15 +87,30 @@ async function readJsonResponse(response, context, isExpectedResponse) {
86
87
  function createLeaseClient(options) {
87
88
  const fetchImpl = options.fetchImpl ?? fetch;
88
89
  const baseUrl = options.controllerUrl.replace(/\/$/u, "");
90
+ const fetchController = async (optionsForRequest) => await fetchControllerWithPolicy({
91
+ fetchImpl,
92
+ input: optionsForRequest.input,
93
+ operation: optionsForRequest.operation,
94
+ ...optionsForRequest.init === void 0 ? {} : { init: optionsForRequest.init },
95
+ ...options.requestPolicy === void 0 ? {} : { policy: options.requestPolicy }
96
+ });
89
97
  const renewLease = async (leaseId) => {
90
- return await readJsonResponse(await fetchImpl(`${baseUrl}/lease/${encodeURIComponent(leaseId)}/renew`, { method: "POST" }), "Controller lease renew API", isToolVmSshLease);
98
+ return await readJsonResponse(await fetchController({
99
+ input: `${baseUrl}/lease/${encodeURIComponent(leaseId)}/renew`,
100
+ init: { method: "POST" },
101
+ operation: "lease-renew"
102
+ }), "Controller lease renew API", isToolVmSshLease);
91
103
  };
92
104
  return {
93
105
  endActiveUse: async (leaseId, useId, request) => {
94
- const response = await fetchImpl(`${baseUrl}/lease/${encodeURIComponent(leaseId)}/uses/${encodeURIComponent(useId)}`, {
95
- body: JSON.stringify(request),
96
- headers: { "content-type": "application/json" },
97
- method: "DELETE"
106
+ const response = await fetchController({
107
+ input: `${baseUrl}/lease/${encodeURIComponent(leaseId)}/uses/${encodeURIComponent(useId)}`,
108
+ init: {
109
+ body: JSON.stringify(request),
110
+ headers: { "content-type": "application/json" },
111
+ method: "DELETE"
112
+ },
113
+ operation: "lease-use-end"
98
114
  });
99
115
  if (!response.ok) {
100
116
  const errorBody = await readErrorBody(response, "Controller active-use end API");
@@ -105,19 +121,35 @@ function createLeaseClient(options) {
105
121
  status: response.status
106
122
  });
107
123
  }
124
+ await drainControllerResponseBody(response);
108
125
  },
109
- heartbeatActiveUse: async (leaseId, useId) => {
110
- return await readJsonResponse(await fetchImpl(`${baseUrl}/lease/${encodeURIComponent(leaseId)}/uses/${encodeURIComponent(useId)}/heartbeat`, { method: "POST" }), "Controller active-use heartbeat API", isHeartbeatActiveUseResponse);
126
+ heartbeatActiveUse: async (leaseId, useId, request) => {
127
+ return await readJsonResponse(await fetchController({
128
+ input: `${baseUrl}/lease/${encodeURIComponent(leaseId)}/uses/${encodeURIComponent(useId)}/heartbeat`,
129
+ init: {
130
+ body: JSON.stringify(request),
131
+ headers: { "content-type": "application/json" },
132
+ method: "POST"
133
+ },
134
+ operation: "lease-heartbeat"
135
+ }), "Controller active-use heartbeat API", isHeartbeatActiveUseResponse);
111
136
  },
112
137
  renewLease,
113
138
  peekLease: async (leaseId) => {
114
- return await readJsonResponse(await fetchImpl(`${baseUrl}/lease/${encodeURIComponent(leaseId)}/peek`), "Controller lease peek API", isToolVmLeasePeek);
139
+ return await readJsonResponse(await fetchController({
140
+ input: `${baseUrl}/lease/${encodeURIComponent(leaseId)}/peek`,
141
+ operation: "lease-peek"
142
+ }), "Controller lease peek API", isToolVmLeasePeek);
115
143
  },
116
144
  publishOpenClawRuntimeStatus: async (report) => {
117
- const response = await fetchImpl(`${baseUrl}/zones/${encodeURIComponent(report.zoneId)}/openclaw-runtime-status`, {
118
- body: JSON.stringify(report),
119
- headers: { "content-type": "application/json" },
120
- method: "POST"
145
+ const response = await fetchController({
146
+ input: `${baseUrl}/zones/${encodeURIComponent(report.zoneId)}/openclaw-runtime-status`,
147
+ init: {
148
+ body: JSON.stringify(report),
149
+ headers: { "content-type": "application/json" },
150
+ method: "POST"
151
+ },
152
+ operation: "openclaw-runtime-status"
121
153
  });
122
154
  if (!response.ok) {
123
155
  const errorBody = await readErrorBody(response, "Controller OpenClaw runtime status API");
@@ -128,11 +160,16 @@ function createLeaseClient(options) {
128
160
  status: response.status
129
161
  });
130
162
  }
163
+ await drainControllerResponseBody(response);
131
164
  },
132
165
  releaseLease: async (leaseId, releaseOptions = {}) => {
133
166
  const releaseUrl = new URL(`${baseUrl}/lease/${encodeURIComponent(leaseId)}`);
134
167
  if (releaseOptions.force === true) releaseUrl.searchParams.set("force", "true");
135
- const response = await fetchImpl(releaseUrl.toString(), { method: "DELETE" });
168
+ const response = await fetchController({
169
+ input: releaseUrl.toString(),
170
+ init: { method: "DELETE" },
171
+ operation: "lease-release"
172
+ });
136
173
  if (!response.ok) {
137
174
  const errorBody = await readErrorBody(response, "Controller lease release API");
138
175
  throw new ControllerLeaseRequestError({
@@ -142,28 +179,36 @@ function createLeaseClient(options) {
142
179
  status: response.status
143
180
  });
144
181
  }
182
+ await drainControllerResponseBody(response);
145
183
  },
146
184
  requestLease: async (request) => {
147
- return await readJsonResponse(await fetchImpl(`${baseUrl}/lease`, {
148
- body: JSON.stringify({
149
- agentId: request.agentId,
150
- agentWorkspaceDir: request.agentWorkspaceDir,
151
- profileId: request.profileId,
152
- sandbox: request.sandbox,
153
- scopeKey: request.scopeKey,
154
- sessionKey: request.sessionKey,
155
- workMountDir: request.workMountDir,
156
- zoneId: request.zoneId
157
- }),
158
- headers: { "content-type": "application/json" },
159
- method: "POST"
185
+ return await readJsonResponse(await fetchController({
186
+ input: `${baseUrl}/lease`,
187
+ init: {
188
+ body: JSON.stringify({
189
+ agentId: request.agentId,
190
+ agentWorkspaceDir: request.agentWorkspaceDir,
191
+ ...request.idleTtlMs !== void 0 ? { idleTtlMs: request.idleTtlMs } : {},
192
+ profileId: request.profileId,
193
+ sessionKey: request.sessionKey,
194
+ workMountDir: request.workMountDir,
195
+ zoneId: request.zoneId
196
+ }),
197
+ headers: { "content-type": "application/json" },
198
+ method: "POST"
199
+ },
200
+ operation: "lease-create"
160
201
  }), "Controller lease API", isToolVmSshLease);
161
202
  },
162
203
  startActiveUse: async (leaseId, request) => {
163
- return await readJsonResponse(await fetchImpl(`${baseUrl}/lease/${encodeURIComponent(leaseId)}/uses`, {
164
- body: JSON.stringify(request),
165
- headers: { "content-type": "application/json" },
166
- method: "POST"
204
+ return await readJsonResponse(await fetchController({
205
+ input: `${baseUrl}/lease/${encodeURIComponent(leaseId)}/uses`,
206
+ init: {
207
+ body: JSON.stringify(request),
208
+ headers: { "content-type": "application/json" },
209
+ method: "POST"
210
+ },
211
+ operation: "lease-use-start"
167
212
  }), "Controller active-use start API", isStartActiveUseResponse);
168
213
  }
169
214
  };
@@ -190,7 +235,13 @@ const OPENCLAW_GONDOLIN_SANDBOX_REQUIREMENTS = [
190
235
  key: "workspaceAccess"
191
236
  }
192
237
  ];
193
- const OPENCLAW_GONDOLIN_LEASE_SCOPE_GUIDANCE = "Managed OpenClaw/Gondolin requires an explicit agentId. scopeKey is OpenClaw scope provenance and may include channel, session, thread, or subagent segments under that agent.";
238
+ const OPENCLAW_GONDOLIN_LEASE_SCOPE_GUIDANCE = "Managed OpenClaw/Gondolin leases are agent-scoped. The plugin derives agentId from sessionKey and does not send OpenClaw scope keys to the controller.";
239
+ var OpenClawAgentIdError = class extends Error {
240
+ constructor(message) {
241
+ super(message);
242
+ this.name = "OpenClawAgentIdError";
243
+ }
244
+ };
194
245
  function isOpenClawAgentId(value) {
195
246
  return agentIdPattern.test(value.trim());
196
247
  }
@@ -208,11 +259,13 @@ function formatOpenClawGondolinRequirementHint(options) {
208
259
  }
209
260
  function normalizeOpenClawAgentId(value) {
210
261
  const trimmed = (value ?? "").trim().toLowerCase();
211
- return isOpenClawAgentId(trimmed) ? trimmed : OPENCLAW_DEFAULT_AGENT_ID;
262
+ if (trimmed === "") return OPENCLAW_DEFAULT_AGENT_ID;
263
+ if (!isOpenClawAgentId(trimmed)) throw new OpenClawAgentIdError(`Invalid OpenClaw agentId '${value}'.`);
264
+ return trimmed;
212
265
  }
213
266
  function resolveOpenClawAgentIdFromSessionKey(sessionKey) {
214
267
  const parts = sessionKey.trim().split(":");
215
- if (parts[0] !== "agent" || !parts[1]) return OPENCLAW_DEFAULT_AGENT_ID;
268
+ if (parts[0] !== "agent" || !parts[1] || !isOpenClawAgentId(parts[1])) throw new OpenClawAgentIdError(`OpenClaw sessionKey '${sessionKey}' must be agent-shaped and include a valid agentId.`);
216
269
  return normalizeOpenClawAgentId(parts[1]);
217
270
  }
218
271
  function isOpenClawAgentSessionKey(sessionKey) {
@@ -231,21 +284,385 @@ function findOpenClawGondolinSandboxMismatch(sandbox) {
231
284
  return OPENCLAW_GONDOLIN_SANDBOX_REQUIREMENTS.find((requirement) => sandbox[requirement.key] !== requirement.expectedValue);
232
285
  }
233
286
  //#endregion
287
+ //#region src/sandbox-backend/openclaw-agent-workspace-source.ts
288
+ var OpenClawAgentWorkspaceSourceError = class extends Error {
289
+ constructor(message) {
290
+ super(message);
291
+ this.name = "OpenClawAgentWorkspaceSourceError";
292
+ }
293
+ };
294
+ function isRecord(value) {
295
+ return typeof value === "object" && value !== null && !Array.isArray(value);
296
+ }
297
+ function normalizeAbsolutePosixPath(inputPath) {
298
+ return `/${inputPath.split("/").filter((segment) => segment !== "" && segment !== ".").join("/")}`;
299
+ }
300
+ function containsParentTraversal(inputPath) {
301
+ return inputPath.split(/\/+/u).includes("..");
302
+ }
303
+ function pathIsInsideOrEqual(inputPath, rootPath) {
304
+ return inputPath === rootPath || inputPath.startsWith(`${rootPath}/`);
305
+ }
306
+ function isRuntimePathLeak(inputPath, defaultWorkspaceDir) {
307
+ const normalized = normalizeAbsolutePosixPath(inputPath);
308
+ const normalizedDefaultWorkspace = defaultWorkspaceDir === void 0 ? void 0 : normalizeAbsolutePosixPath(resolveUserPathLikeOpenClaw(defaultWorkspaceDir));
309
+ const implicitWorkspaceFamilyRoot = normalizedDefaultWorkspace === void 0 ? void 0 : normalizedDefaultWorkspace.replace(/(?:-[^/]+)?$/u, "");
310
+ return normalized === TOOL_VM_WORKSPACE_GUEST_ROOT || normalized.startsWith(`${TOOL_VM_WORKSPACE_GUEST_ROOT}/`) || normalized === TOOL_VM_SCRATCH_GUEST_ROOT || normalized.startsWith(`${TOOL_VM_SCRATCH_GUEST_ROOT}/`) || normalized === OPENCLAW_STATE_SANDBOXES_VM_ROOT || normalized.startsWith(`${OPENCLAW_STATE_SANDBOXES_VM_ROOT}/`) || normalizedDefaultWorkspace !== void 0 && (pathIsInsideOrEqual(normalized, normalizedDefaultWorkspace) || normalized.startsWith(`${normalizedDefaultWorkspace}-`)) || implicitWorkspaceFamilyRoot !== void 0 && (pathIsInsideOrEqual(normalized, implicitWorkspaceFamilyRoot) || normalized.startsWith(`${implicitWorkspaceFamilyRoot}-`));
311
+ }
312
+ function resolveUserPathLikeOpenClaw(inputPath) {
313
+ const trimmedPath = inputPath.trim();
314
+ const homeDirectory = process.env.HOME?.trim();
315
+ if (trimmedPath === "~" && homeDirectory) return homeDirectory;
316
+ if (trimmedPath.startsWith("~/") && homeDirectory) return path.resolve(path.join(homeDirectory, trimmedPath.slice(2)));
317
+ return path.resolve(trimmedPath);
318
+ }
319
+ function assertCanonicalSourcePath(inputPath, context) {
320
+ const trimmedPath = inputPath.trim();
321
+ if (trimmedPath === "" || containsParentTraversal(trimmedPath)) throw new OpenClawAgentWorkspaceSourceError(`${context} must be a non-empty path without parent traversal.`);
322
+ if (!trimmedPath.startsWith("/") && !trimmedPath.startsWith("~")) throw new OpenClawAgentWorkspaceSourceError(`${context} must be an absolute or home-relative path.`);
323
+ const normalized = normalizeAbsolutePosixPath(resolveUserPathLikeOpenClaw(trimmedPath));
324
+ if (normalized === "/" || normalized === TOOL_VM_WORKSPACE_GUEST_ROOT || normalized.startsWith(`${TOOL_VM_WORKSPACE_GUEST_ROOT}/`) || normalized === TOOL_VM_SCRATCH_GUEST_ROOT || normalized.startsWith(`${TOOL_VM_SCRATCH_GUEST_ROOT}/`)) throw new OpenClawAgentWorkspaceSourceError(`${context} must resolve to an OpenClaw/Gondolin source path, not Tool VM guest path '${normalized}'.`);
325
+ if (normalized === OPENCLAW_STATE_SANDBOXES_VM_ROOT || normalized.startsWith(`${OPENCLAW_STATE_SANDBOXES_VM_ROOT}/`)) throw new OpenClawAgentWorkspaceSourceError(`${context} must resolve to a stable agent workspace path, not transient OpenClaw sandbox path '${normalized}'.`);
326
+ return normalized;
327
+ }
328
+ function assertLeaseBackedSourcePath(inputPath, context, defaultWorkspaceDir) {
329
+ const normalized = assertCanonicalSourcePath(inputPath, context);
330
+ if (isRuntimePathLeak(normalized, defaultWorkspaceDir)) throw new OpenClawAgentWorkspaceSourceError(`${context} must resolve to a controller lease-backed OpenClaw/Gondolin source path, not OpenClaw runtime fallback path '${normalized}'.`);
331
+ return normalized;
332
+ }
333
+ function readWorkspace(value) {
334
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
335
+ }
336
+ function readAgentId(value) {
337
+ return normalizeOpenClawAgentId(typeof value === "string" ? value : void 0);
338
+ }
339
+ function agentEntries(config) {
340
+ return config?.agents?.list?.filter(isRecord) ?? [];
341
+ }
342
+ function findAgentEntry(config, agentId) {
343
+ return agentEntries(config).find((entry) => readAgentId(entry.id) === agentId);
344
+ }
345
+ function resolveDefaultAgentId(config) {
346
+ const entries = agentEntries(config);
347
+ return readAgentId((entries.find((entry) => entry.default === true) ?? entries[0])?.id);
348
+ }
349
+ function resolveOpenClawAgentWorkspaceSource(options) {
350
+ const agentId = normalizeOpenClawAgentId(options.agentId);
351
+ const agentWorkspace = readWorkspace(findAgentEntry(options.openClawConfig, agentId)?.workspace);
352
+ if (agentWorkspace !== void 0) return {
353
+ kind: "configured-agent-workspace",
354
+ sourceDir: assertLeaseBackedSourcePath(agentWorkspace, `agents.list workspace for '${agentId}'`, options.defaultWorkspaceDir)
355
+ };
356
+ const defaultsWorkspace = readWorkspace(options.openClawConfig?.agents?.defaults?.workspace);
357
+ if (defaultsWorkspace !== void 0) {
358
+ const defaultsRoot = assertLeaseBackedSourcePath(defaultsWorkspace, "agents.defaults.workspace", options.defaultWorkspaceDir);
359
+ const defaultAgentId = resolveDefaultAgentId(options.openClawConfig);
360
+ return {
361
+ kind: agentId === defaultAgentId ? "default-agent-workspace" : "default-workspace-child",
362
+ sourceDir: agentId === defaultAgentId ? defaultsRoot : path.join(defaultsRoot, agentId)
363
+ };
364
+ }
365
+ if (!isRuntimePathLeak(options.paramsAgentWorkspaceDir, options.defaultWorkspaceDir)) return {
366
+ kind: "sdk-agent-workspace",
367
+ sourceDir: assertCanonicalSourcePath(options.paramsAgentWorkspaceDir, "OpenClaw backend agentWorkspaceDir")
368
+ };
369
+ const stateRoot = options.stateDir === void 0 ? void 0 : assertCanonicalSourcePath(options.stateDir, "OpenClaw stateDir");
370
+ if (stateRoot === void 0) throw new OpenClawAgentWorkspaceSourceError(`OpenClaw provided agentWorkspaceDir '${options.paramsAgentWorkspaceDir}' for agent '${agentId}', which is a runtime path. Provide an OpenClaw stateDir provider or configure agents.list[].workspace.`);
371
+ if (agentId === resolveDefaultAgentId(options.openClawConfig)) throw new OpenClawAgentWorkspaceSourceError(`OpenClaw provided agentWorkspaceDir '${options.paramsAgentWorkspaceDir}' for default agent '${agentId}', but OpenClaw's implicit default workspace is not controller lease backed; configure agents.list[].workspace or agents.defaults.workspace for managed Gondolin agents.`);
372
+ return {
373
+ kind: "state-workspace-child",
374
+ sourceDir: path.join(stateRoot, `workspace-${agentId}`)
375
+ };
376
+ }
377
+ //#endregion
378
+ //#region src/sandbox-backend/openclaw-tool-vm-path-mapping.ts
379
+ var OpenClawToolVmPathIntentError = class extends Error {
380
+ details;
381
+ constructor(details) {
382
+ super(`${details.message} ${details.retryGuidance}`);
383
+ this.name = "OpenClawToolVmPathIntentError";
384
+ this.details = details;
385
+ }
386
+ };
387
+ function pathContainsParentTraversal(inputPath) {
388
+ return inputPath.split(/\/+/u).includes("..");
389
+ }
390
+ function normalizedAbsolutePath(inputPath) {
391
+ return `/${inputPath.split("/").filter((segment) => segment !== "" && segment !== ".").join("/")}`;
392
+ }
393
+ function invalidAgentWorkspaceRootError(agentWorkspaceDir) {
394
+ return {
395
+ allowedPathForms: [],
396
+ code: "invalid-runtime-root",
397
+ inputPath: agentWorkspaceDir,
398
+ mappingId: "openclaw-tool-vm",
399
+ message: `OpenClaw agentWorkspaceDir '${agentWorkspaceDir}' must be an absolute non-root path without parent traversal.`,
400
+ purpose: "executionCwd",
401
+ retryGuidance: "Retry with OpenClaw agentWorkspaceDir set to the resolved host RealFS workspace for the requested agent."
402
+ };
403
+ }
404
+ function validateAgentWorkspaceDir(agentWorkspaceDir) {
405
+ if (agentWorkspaceDir.trim() === "" || !agentWorkspaceDir.startsWith("/") || normalizedAbsolutePath(agentWorkspaceDir) === "/" || pathContainsParentTraversal(agentWorkspaceDir)) return invalidAgentWorkspaceRootError(agentWorkspaceDir);
406
+ }
407
+ function createOpenClawToolVmPathMapping(options) {
408
+ return {
409
+ id: "openclaw-tool-vm",
410
+ roots: [
411
+ {
412
+ id: "agent-workspace",
413
+ backing: {
414
+ kind: "host-realfs",
415
+ durability: "durable",
416
+ backup: "included"
417
+ },
418
+ capabilities: {
419
+ executionCwd: true,
420
+ leaseMount: true
421
+ },
422
+ locations: {
423
+ "openclaw-gateway": options.agentWorkspaceDir,
424
+ "tool-vm-guest": TOOL_VM_WORKSPACE_GUEST_ROOT
425
+ },
426
+ rootPathAllowed: true,
427
+ guidanceLabel: "agent workspace"
428
+ },
429
+ {
430
+ id: "tool-vm-scratch",
431
+ backing: {
432
+ kind: "guest-rootfs-cow",
433
+ durability: "vm-lifetime"
434
+ },
435
+ capabilities: {
436
+ executionCwd: true,
437
+ leaseMount: false
438
+ },
439
+ locations: { "tool-vm-guest": TOOL_VM_SCRATCH_GUEST_ROOT },
440
+ rootPathAllowed: true,
441
+ guidanceLabel: "Tool VM scratch"
442
+ },
443
+ {
444
+ id: "openclaw-sandboxes",
445
+ backing: {
446
+ kind: "host-realfs",
447
+ durability: "durable",
448
+ backup: "included"
449
+ },
450
+ capabilities: {
451
+ executionCwd: true,
452
+ leaseMount: true
453
+ },
454
+ locations: { "openclaw-gateway": OPENCLAW_STATE_SANDBOXES_VM_ROOT },
455
+ rootPathAllowed: false,
456
+ guidanceLabel: "OpenClaw sandbox work directory"
457
+ }
458
+ ]
459
+ };
460
+ }
461
+ function resolveOpenClawSandboxPathIntent(translation) {
462
+ const [sandboxChild, ...guestCwdSegments] = translation.relativePath.split("/");
463
+ const leaseWorkMountDir = sandboxChild === void 0 || sandboxChild === "" ? translation.outputPath : `${OPENCLAW_STATE_SANDBOXES_VM_ROOT}/${sandboxChild}`;
464
+ return {
465
+ effectiveGuestCwd: guestCwdSegments.length === 0 ? TOOL_VM_WORKSPACE_GUEST_ROOT : `${TOOL_VM_WORKSPACE_GUEST_ROOT}/${guestCwdSegments.join("/")}`,
466
+ leaseWorkMountDir
467
+ };
468
+ }
469
+ function kindForTranslation(translation) {
470
+ const isRoot = translation.relativePath === "";
471
+ if (translation.rootId === "tool-vm-scratch") return isRoot ? "scratch-root" : "scratch-subpath";
472
+ if (translation.rootId === "openclaw-sandboxes") return "openclaw-sandbox-path";
473
+ if (translation.inputNamespace === "openclaw-gateway") return isRoot ? "host-workspace-root" : "host-workspace-subpath";
474
+ return isRoot ? "workspace-root" : "workspace-subpath";
475
+ }
476
+ function leaseRootForTranslation(translation) {
477
+ return translation.relativePath === "" ? translation.outputPath : translation.outputPath.slice(0, -(translation.relativePath.length + 1));
478
+ }
479
+ function resolveOpenClawToolVmPathIntent(options) {
480
+ const agentWorkspaceDirError = validateAgentWorkspaceDir(options.agentWorkspaceDir);
481
+ if (agentWorkspaceDirError !== void 0) return {
482
+ error: agentWorkspaceDirError,
483
+ ok: false
484
+ };
485
+ const mappings = [createOpenClawToolVmPathMapping({ agentWorkspaceDir: options.agentWorkspaceDir }), ...(options.equivalentAgentWorkspaceDirs ?? []).map((equivalentAgentWorkspaceDir) => createOpenClawToolVmPathMapping({ agentWorkspaceDir: equivalentAgentWorkspaceDir }))];
486
+ const invalidEquivalentRoot = (options.equivalentAgentWorkspaceDirs ?? []).map((equivalentAgentWorkspaceDir) => validateAgentWorkspaceDir(equivalentAgentWorkspaceDir)).find((error) => error !== void 0);
487
+ if (invalidEquivalentRoot !== void 0) return {
488
+ error: invalidEquivalentRoot,
489
+ ok: false
490
+ };
491
+ const mapping = createOpenClawToolVmPathMapping({ agentWorkspaceDir: options.agentWorkspaceDir });
492
+ const sandboxTranslation = translateRuntimePath({
493
+ inputPath: options.inputPath,
494
+ mapping,
495
+ purpose: "executionCwd",
496
+ sourceNamespace: "openclaw-gateway",
497
+ targetNamespace: "openclaw-gateway"
498
+ });
499
+ if (sandboxTranslation.ok && sandboxTranslation.value.rootId === "openclaw-sandboxes") {
500
+ const sandboxPathIntent = resolveOpenClawSandboxPathIntent(sandboxTranslation.value);
501
+ return {
502
+ ok: true,
503
+ value: {
504
+ effectiveGuestCwd: sandboxPathIntent.effectiveGuestCwd,
505
+ hostEquivalentPath: sandboxTranslation.value.outputPath,
506
+ kind: kindForTranslation(sandboxTranslation.value),
507
+ leaseWorkMountDir: sandboxPathIntent.leaseWorkMountDir
508
+ }
509
+ };
510
+ }
511
+ const translationResults = mappings.map((candidateMapping) => translateRuntimePath({
512
+ inputPath: options.inputPath,
513
+ mapping: candidateMapping,
514
+ purpose: "executionCwd",
515
+ targetNamespace: "tool-vm-guest"
516
+ }));
517
+ const translation = translationResults.find((candidateTranslation) => candidateTranslation.ok);
518
+ if (translation === void 0) {
519
+ const primaryTranslation = translationResults[0];
520
+ if (primaryTranslation === void 0 || primaryTranslation.ok) return {
521
+ error: invalidAgentWorkspaceRootError(options.agentWorkspaceDir),
522
+ ok: false
523
+ };
524
+ return primaryTranslation;
525
+ }
526
+ const hostEquivalentTranslation = translateRuntimePath({
527
+ inputPath: options.inputPath,
528
+ mapping,
529
+ purpose: "executionCwd",
530
+ targetNamespace: "openclaw-gateway"
531
+ });
532
+ return {
533
+ ok: true,
534
+ value: {
535
+ effectiveGuestCwd: translation.value.outputPath,
536
+ ...hostEquivalentTranslation.ok ? { hostEquivalentPath: hostEquivalentTranslation.value.outputPath } : {},
537
+ kind: kindForTranslation(translation.value),
538
+ leaseWorkMountDir: hostEquivalentTranslation.ok && hostEquivalentTranslation.value.rootId !== "tool-vm-scratch" ? leaseRootForTranslation(hostEquivalentTranslation.value) : options.agentWorkspaceDir
539
+ }
540
+ };
541
+ }
542
+ function assertOpenClawToolVmPathIntent(options) {
543
+ const result = resolveOpenClawToolVmPathIntent(options);
544
+ if (!result.ok) throw new OpenClawToolVmPathIntentError(result.error);
545
+ return result.value;
546
+ }
547
+ //#endregion
234
548
  //#region src/sandbox-backend/sandbox-shell-script.ts
235
549
  function buildShellScriptWithArgs(script, args) {
236
550
  if (!args || args.length === 0) return script;
237
551
  return `set -- ${args.map((arg) => `'${arg.replace(/'/g, "'\\''")}'`).join(" ")}; ${script}`;
238
552
  }
239
553
  //#endregion
554
+ //#region src/sandbox-backend/tool-vm-ssh-operation-guard.ts
555
+ var ToolVmSshOperationStaleError = class extends Error {
556
+ cause;
557
+ reason;
558
+ constructor(options) {
559
+ super(options.message);
560
+ this.cause = options.cause;
561
+ this.reason = options.reason;
562
+ }
563
+ };
564
+ function formatUnknownError$1(error) {
565
+ return error instanceof Error ? error.message : String(error);
566
+ }
567
+ function defaultWriteLog$1(message) {
568
+ process.stderr.write(`[tool-vm-ssh-operation-guard] ${message}\n`);
569
+ }
570
+ async function publishHealthEvent(options) {
571
+ if (!options.guardOptions.healthEvent) return;
572
+ const event = {
573
+ agentId: options.guardOptions.healthEvent.agentId,
574
+ elapsedMs: options.elapsedMs,
575
+ ...options.errorCode === void 0 ? {} : { errorCode: options.errorCode },
576
+ kind: "tool-vm-ssh",
577
+ leaseId: options.guardOptions.healthEvent.leaseId,
578
+ observedAtMs: options.observedAtMs,
579
+ operation: options.guardOptions.healthEvent.operation,
580
+ result: options.result,
581
+ zoneId: options.guardOptions.healthEvent.zoneId
582
+ };
583
+ try {
584
+ await options.guardOptions.healthEvent.publish(event);
585
+ } catch (error) {
586
+ (options.guardOptions.writeLog ?? defaultWriteLog$1)(`tool-vm-ssh health publish failed operation=${options.guardOptions.healthEvent.operation} elapsedMs=${String(options.elapsedMs)} error=${formatUnknownError$1(error)}`);
587
+ }
588
+ }
589
+ async function runToolVmSshOperationWithGuard(options) {
590
+ const now = options.now ?? Date.now;
591
+ const setTimeoutImpl = options.setTimeoutImpl ?? setTimeout;
592
+ const clearTimeoutImpl = options.clearTimeoutImpl ?? clearTimeout;
593
+ const abortController = new AbortController();
594
+ const startedAtMs = now();
595
+ let timeoutHandle;
596
+ options.report({
597
+ observedAtMs: now(),
598
+ phase: "running"
599
+ });
600
+ const timeoutPromise = new Promise((_resolve, reject) => {
601
+ timeoutHandle = setTimeoutImpl(() => {
602
+ abortController.abort();
603
+ reject(new ToolVmSshOperationStaleError({
604
+ cause: void 0,
605
+ message: `${options.operationName} exceeded ${String(options.timeoutMs)}ms.`,
606
+ reason: "ssh-command-timed-out"
607
+ }));
608
+ }, options.timeoutMs);
609
+ });
610
+ try {
611
+ const result = await Promise.race([options.operation(abortController.signal), timeoutPromise]);
612
+ options.report({
613
+ observedAtMs: now(),
614
+ phase: "completed",
615
+ ssh: { probeSucceeded: true }
616
+ });
617
+ const observedAtMs = now();
618
+ publishHealthEvent({
619
+ elapsedMs: observedAtMs - startedAtMs,
620
+ guardOptions: options,
621
+ observedAtMs,
622
+ result: "ok"
623
+ });
624
+ return result;
625
+ } catch (error) {
626
+ const staleError = error instanceof ToolVmSshOperationStaleError ? error : new ToolVmSshOperationStaleError({
627
+ cause: error,
628
+ message: formatUnknownError$1(error),
629
+ reason: "ssh-command-failed"
630
+ });
631
+ options.report({
632
+ observedAtMs: now(),
633
+ phase: "failed",
634
+ ssh: { failure: {
635
+ kind: staleError.reason,
636
+ message: staleError.message
637
+ } }
638
+ });
639
+ const observedAtMs = now();
640
+ publishHealthEvent({
641
+ elapsedMs: observedAtMs - startedAtMs,
642
+ errorCode: staleError.reason,
643
+ guardOptions: options,
644
+ observedAtMs,
645
+ result: "failed"
646
+ });
647
+ throw staleError;
648
+ } finally {
649
+ if (timeoutHandle !== void 0) clearTimeoutImpl(timeoutHandle);
650
+ }
651
+ }
652
+ //#endregion
240
653
  //#region src/sandbox-backend/sandbox-backend-handle-factory.ts
241
654
  function agentLeaseCacheKey(params) {
242
- return [
243
- params.zoneId,
244
- params.agentId,
245
- params.profileId,
246
- params.agentWorkspaceDir,
247
- params.workspaceDir
248
- ].join("\0");
655
+ return [params.zoneId, params.agentId].join("\0");
656
+ }
657
+ function findCachedLeaseCompatibilityMismatch(params) {
658
+ if (params.cachedEntry.agentWorkspaceDir !== params.requestedEntry.agentWorkspaceDir) return "agentWorkspaceDir";
659
+ if (params.cachedEntry.leaseWorkMountDir !== params.requestedEntry.leaseWorkMountDir) return "leaseWorkMountDir";
660
+ if (params.cachedEntry.profileId !== params.requestedEntry.profileId) return "profileId";
661
+ }
662
+ function assertCachedLeaseCompatible(params) {
663
+ const mismatch = findCachedLeaseCompatibilityMismatch(params);
664
+ if (mismatch === void 0) return;
665
+ throw new Error(`Cannot reuse cached Tool VM lease for zone '${params.zoneId}' agent '${params.agentId}': ${mismatch} changed.`);
249
666
  }
250
667
  function formatControllerLeaseRequestError(error) {
251
668
  const responseBody = error.responseBody === void 0 ? error.bodyText : JSON.stringify(error.responseBody);
@@ -259,7 +676,10 @@ function writeSandboxBackendLog(message) {
259
676
  process.stderr.write(`[openclaw-agent-vm-plugin] ${message}\n`);
260
677
  }
261
678
  function shouldRefreshCachedLease(error) {
262
- return error instanceof ControllerLeaseRequestError && error.status === 404;
679
+ return isRefreshableLeaseError(error);
680
+ }
681
+ function isRefreshableLeaseError(error) {
682
+ return error instanceof ControllerLeaseRequestError && (error.status === 404 || error.status === 410);
263
683
  }
264
684
  function isCleanupNotFound(error) {
265
685
  return error instanceof ControllerLeaseRequestError && error.status === 404;
@@ -273,111 +693,276 @@ function isActiveUseFinalizeToken(value) {
273
693
  function activeUseOutcomeForFinalizeParams(finalizeParams) {
274
694
  return finalizeParams.timedOut ? "timed-out" : finalizeParams.status === "completed" ? "completed" : "failed";
275
695
  }
696
+ async function publishFinalizeToolVmSshHealthEvent(options) {
697
+ const event = {
698
+ agentId: options.agentId,
699
+ elapsedMs: 0,
700
+ ...options.timedOut ? { errorCode: "ssh-command-timed-out" } : {},
701
+ kind: "tool-vm-ssh",
702
+ leaseId: options.leaseId,
703
+ observedAtMs: Date.now(),
704
+ operation: "finalize",
705
+ result: options.timedOut ? "failed" : "ok",
706
+ zoneId: options.zoneId
707
+ };
708
+ try {
709
+ await options.publishHealthEvent(event);
710
+ } catch (error) {
711
+ writeSandboxBackendLog(`tool-vm-ssh finalize health publish failed for zone '${options.zoneId}' lease '${options.leaseId}': ${formatUnknownError(error)}`);
712
+ }
713
+ }
714
+ function mergedAbortSignal(firstSignal, secondSignal) {
715
+ if (firstSignal === void 0) return secondSignal;
716
+ return AbortSignal.any([firstSignal, secondSignal]);
717
+ }
718
+ function mergedAbortSignals(signals) {
719
+ const presentSignals = signals.filter((signal) => signal !== void 0);
720
+ if (presentSignals.length === 0) return;
721
+ if (presentSignals.length === 1) return presentSignals[0];
722
+ return AbortSignal.any(presentSignals);
723
+ }
276
724
  function resolveLeaseRequestAgentId(sessionKey) {
277
725
  return resolveOpenClawAgentIdFromSessionKey(sessionKey);
278
726
  }
727
+ function defaultOpenClawStateDir() {
728
+ const explicitStateDir = process.env.OPENCLAW_STATE_DIR?.trim();
729
+ if (explicitStateDir) return path.resolve(explicitStateDir);
730
+ const homeDirectory = process.env.HOME?.trim();
731
+ return homeDirectory ? path.join(homeDirectory, ".openclaw", "state") : void 0;
732
+ }
733
+ function defaultOpenClawWorkspaceDir() {
734
+ const homeDirectory = process.env.HOME?.trim();
735
+ if (!homeDirectory) return;
736
+ const profile = process.env.OPENCLAW_PROFILE?.trim().toLowerCase();
737
+ return profile && profile !== "default" ? path.join(homeDirectory, ".openclaw", `workspace-${profile}`) : path.join(homeDirectory, ".openclaw", "workspace");
738
+ }
279
739
  function assertPluginLeaseContract(params) {
280
740
  const mismatch = findOpenClawGondolinSandboxMismatch(params.cfg);
281
741
  if (mismatch) throw new Error(`OpenClaw Gondolin sandbox requires ${mismatch.key}=${mismatch.expectedValue}; received ${String(params.cfg[mismatch.key])}.`);
282
742
  }
283
743
  function createGondolinSandboxBackendFactory(options, dependencies) {
284
- const scopeCache = /* @__PURE__ */ new Map();
744
+ const agentLeaseCache = /* @__PURE__ */ new Map();
745
+ const inFlightLeaseRequests = /* @__PURE__ */ new Map();
285
746
  return async (params) => {
286
747
  const profileId = options.profileId ?? "standard";
287
748
  const agentId = resolveLeaseRequestAgentId(params.sessionKey);
288
- assertPluginLeaseContract({
749
+ assertPluginLeaseContract({ cfg: params.cfg });
750
+ const defaultWorkspaceDir = options.openClawDefaultWorkspaceDirProvider?.() ?? defaultOpenClawWorkspaceDir();
751
+ const equivalentAgentWorkspaceDirs = defaultWorkspaceDir === void 0 ? [] : [defaultWorkspaceDir];
752
+ const workspaceSource = resolveOpenClawAgentWorkspaceSource({
289
753
  agentId,
290
- cfg: params.cfg,
291
- scopeKey: params.scopeKey
754
+ defaultWorkspaceDir,
755
+ openClawConfig: options.openClawRuntimeConfigProvider?.(),
756
+ paramsAgentWorkspaceDir: params.agentWorkspaceDir,
757
+ stateDir: options.openClawStateDirProvider?.() ?? defaultOpenClawStateDir()
758
+ });
759
+ const pathIntent = assertOpenClawToolVmPathIntent({
760
+ agentWorkspaceDir: workspaceSource.sourceDir,
761
+ equivalentAgentWorkspaceDirs,
762
+ inputPath: params.workspaceDir
292
763
  });
293
764
  const cacheKey = agentLeaseCacheKey({
294
765
  agentId,
295
- agentWorkspaceDir: params.agentWorkspaceDir,
296
- profileId,
297
- workspaceDir: params.workspaceDir,
298
766
  zoneId: options.zoneId
299
767
  });
768
+ const requestedCacheEntry = {
769
+ agentWorkspaceDir: workspaceSource.sourceDir,
770
+ leaseWorkMountDir: pathIntent.leaseWorkMountDir,
771
+ profileId
772
+ };
300
773
  const leaseClient = dependencies.createLeaseClient?.({ controllerUrl: options.controllerUrl }) ?? createLeaseClient({ controllerUrl: options.controllerUrl });
301
- const cachedEntry = scopeCache.get(cacheKey);
302
- if (cachedEntry) try {
303
- await leaseClient.renewLease(cachedEntry.lease.leaseId);
304
- return cachedEntry.handle;
305
- } catch (error) {
306
- writeSandboxBackendLog(`lease renew failed for zone '${options.zoneId}' scope '${params.scopeKey}' lease '${cachedEntry.lease.leaseId}': ${formatUnknownError(error)}`);
307
- if (!shouldRefreshCachedLease(error)) throw error;
308
- scopeCache.delete(cacheKey);
774
+ const publishHealthEvent = async (event) => {
775
+ const response = await fetchControllerWithPolicy({
776
+ input: `${options.controllerUrl.replace(/\/+$/u, "")}/zones/${encodeURIComponent(options.zoneId)}/health-events`,
777
+ init: {
778
+ body: JSON.stringify(event),
779
+ headers: { "content-type": "application/json" },
780
+ method: "POST"
781
+ },
782
+ operation: "health-event-publish"
783
+ });
784
+ if (!response.ok) {
785
+ await response.text().catch(() => void 0);
786
+ throw new Error(`health event publish returned HTTP ${String(response.status)}`);
787
+ }
788
+ await response.text().catch(() => void 0);
789
+ };
790
+ const markLeaseStale = async (lease, reason, error) => {
791
+ agentLeaseCache.delete(cacheKey);
792
+ writeSandboxBackendLog(`lease marked stale for zone '${options.zoneId}' agent '${agentId}' lease '${lease.leaseId}' reason '${reason}': ${formatUnknownError(error)}`);
793
+ await leaseClient.releaseLease(lease.leaseId, { force: true }).catch((releaseError) => {
794
+ writeSandboxBackendLog(`best-effort stale lease release failed for zone '${options.zoneId}' agent '${agentId}' lease '${lease.leaseId}': ${formatUnknownError(releaseError)}`);
795
+ });
796
+ };
797
+ const cachedEntry = agentLeaseCache.get(cacheKey);
798
+ let lease;
799
+ if (cachedEntry) {
800
+ assertCachedLeaseCompatible({
801
+ agentId,
802
+ cachedEntry,
803
+ requestedEntry: requestedCacheEntry,
804
+ zoneId: options.zoneId
805
+ });
806
+ try {
807
+ const renewedLease = await leaseClient.renewLease(cachedEntry.lease.leaseId);
808
+ await runToolVmSshOperationWithGuard({
809
+ healthEvent: {
810
+ agentId,
811
+ leaseId: renewedLease.leaseId,
812
+ operation: "probe",
813
+ publish: publishHealthEvent,
814
+ zoneId: options.zoneId
815
+ },
816
+ operation: async (signal) => await dependencies.runRemoteShellScript({
817
+ allowFailure: false,
818
+ script: "true",
819
+ signal,
820
+ ssh: renewedLease.ssh
821
+ }),
822
+ operationName: "cached-ssh-probe",
823
+ report: () => {},
824
+ timeoutMs: 3e4
825
+ });
826
+ lease = renewedLease;
827
+ agentLeaseCache.set(cacheKey, {
828
+ ...requestedCacheEntry,
829
+ lease
830
+ });
831
+ } catch (error) {
832
+ writeSandboxBackendLog(`lease renew failed for zone '${options.zoneId}' agent '${agentId}' lease '${cachedEntry.lease.leaseId}': ${formatUnknownError(error)}`);
833
+ if (error instanceof ToolVmSshOperationStaleError) await markLeaseStale(cachedEntry.lease, error.reason, error);
834
+ else if (shouldRefreshCachedLease(error)) agentLeaseCache.delete(cacheKey);
835
+ else throw error;
836
+ }
309
837
  }
310
- const runtimeStatus = options.openClawRuntimeStatusProvider?.();
311
- if (runtimeStatus && leaseClient.publishOpenClawRuntimeStatus) await leaseClient.publishOpenClawRuntimeStatus(runtimeStatus);
312
- const leaseResponse = await leaseClient.requestLease({
313
- agentId,
314
- agentWorkspaceDir: params.agentWorkspaceDir,
315
- profileId,
316
- sandbox: snapshotOpenClawGondolinSandboxConfig(params.cfg),
317
- scopeKey: params.scopeKey,
318
- sessionKey: params.sessionKey,
319
- workMountDir: params.workspaceDir,
320
- zoneId: options.zoneId
321
- });
322
- if (!isToolVmSshLease(leaseResponse)) throw new TypeError("Controller lease API returned an unexpected response.");
323
- const lease = leaseResponse;
324
- const handle = createSandboxBackendHandle({
838
+ if (lease === void 0) {
839
+ const inFlightLeaseRequest = inFlightLeaseRequests.get(cacheKey);
840
+ if (inFlightLeaseRequest !== void 0) {
841
+ const inFlightEntry = await inFlightLeaseRequest;
842
+ assertCachedLeaseCompatible({
843
+ agentId,
844
+ cachedEntry: inFlightEntry,
845
+ requestedEntry: requestedCacheEntry,
846
+ zoneId: options.zoneId
847
+ });
848
+ lease = inFlightEntry.lease;
849
+ } else {
850
+ const leaseRequestPromise = (async () => {
851
+ const runtimeStatus = options.openClawRuntimeStatusProvider?.();
852
+ if (runtimeStatus && leaseClient.publishOpenClawRuntimeStatus) await leaseClient.publishOpenClawRuntimeStatus(runtimeStatus);
853
+ const leaseResponse = await leaseClient.requestLease({
854
+ agentId,
855
+ agentWorkspaceDir: workspaceSource.sourceDir,
856
+ profileId,
857
+ sessionKey: params.sessionKey,
858
+ workMountDir: pathIntent.leaseWorkMountDir,
859
+ zoneId: options.zoneId
860
+ });
861
+ if (!isToolVmSshLease(leaseResponse)) throw new TypeError("Controller lease API returned an unexpected response.");
862
+ return {
863
+ ...requestedCacheEntry,
864
+ lease: leaseResponse
865
+ };
866
+ })();
867
+ inFlightLeaseRequests.set(cacheKey, leaseRequestPromise);
868
+ try {
869
+ const leaseEntry = await leaseRequestPromise;
870
+ agentLeaseCache.set(cacheKey, leaseEntry);
871
+ lease = leaseEntry.lease;
872
+ } finally {
873
+ if (inFlightLeaseRequests.get(cacheKey) === leaseRequestPromise) inFlightLeaseRequests.delete(cacheKey);
874
+ }
875
+ }
876
+ }
877
+ return createSandboxBackendHandle({
325
878
  cfg: params.cfg,
326
879
  controllerUrl: options.controllerUrl,
327
880
  createFsBridgeBuilder: dependencies.createFsBridgeBuilder,
881
+ effectiveGuestCwd: pathIntent.effectiveGuestCwd,
328
882
  lease,
329
883
  leaseClient,
884
+ markCachedLeaseStale: async (reason, error) => {
885
+ await markLeaseStale(lease, reason, error);
886
+ },
887
+ publishHealthEvent,
330
888
  runRemoteShellScript: dependencies.runRemoteShellScript,
331
889
  buildExecSpec: dependencies.buildExecSpec,
332
- scopeKey: params.scopeKey,
333
890
  sessionKey: params.sessionKey,
334
891
  zoneId: options.zoneId
335
892
  });
336
- scopeCache.set(cacheKey, {
337
- handle,
338
- lease
339
- });
340
- return handle;
341
893
  };
342
894
  }
343
895
  function createSandboxBackendHandle(options) {
344
- const createActiveUseHandle = async (correlation) => await createToolVmActiveUseHandle({
345
- correlation,
346
- endActiveUse: async (useId, request) => {
347
- await options.leaseClient.endActiveUse(options.lease.leaseId, useId, request);
348
- },
349
- heartbeatActiveUse: async (useId) => await options.leaseClient.heartbeatActiveUse(options.lease.leaseId, useId),
350
- isEndErrorTolerable: isCleanupNotFound,
351
- logEndFailure: (error) => {
352
- writeSandboxBackendLog(`active-use cleanup ignored for zone '${options.zoneId}' lease '${options.lease.leaseId}': ${formatUnknownError(error)}`);
353
- },
354
- logHeartbeatFailure: (error) => {
355
- writeSandboxBackendLog(`active-use heartbeat failed for zone '${options.zoneId}' lease '${options.lease.leaseId}': ${formatUnknownError(error)}`);
356
- },
357
- startActiveUse: async (request) => await options.leaseClient.startActiveUse(options.lease.leaseId, request)
358
- });
896
+ const createActiveUseHandle = async (correlation) => {
897
+ try {
898
+ return await createToolVmActiveUseHandle({
899
+ correlation,
900
+ endActiveUse: async (useId, request) => {
901
+ await options.leaseClient.endActiveUse(options.lease.leaseId, useId, request);
902
+ },
903
+ heartbeatActiveUse: async (useId, request) => await options.leaseClient.heartbeatActiveUse(options.lease.leaseId, useId, request),
904
+ isEndErrorTolerable: isCleanupNotFound,
905
+ isHeartbeatErrorRefreshable: isRefreshableLeaseError,
906
+ logEndFailure: (error) => {
907
+ writeSandboxBackendLog(`active-use cleanup ignored for zone '${options.zoneId}' lease '${options.lease.leaseId}': ${formatUnknownError(error)}`);
908
+ },
909
+ logHeartbeatFailure: (error) => {
910
+ writeSandboxBackendLog(`active-use heartbeat failed for zone '${options.zoneId}' lease '${options.lease.leaseId}': ${formatUnknownError(error)}`);
911
+ },
912
+ onRefreshableHeartbeatFailure: async (error) => {
913
+ await options.markCachedLeaseStale("active-use-refreshable-failure", error);
914
+ },
915
+ startActiveUse: async (request) => await options.leaseClient.startActiveUse(options.lease.leaseId, request)
916
+ });
917
+ } catch (error) {
918
+ if (isRefreshableLeaseError(error)) await options.markCachedLeaseStale("active-use-refreshable-failure", error);
919
+ throw error;
920
+ }
921
+ };
359
922
  const runWithActiveUse = async (correlation, fn) => {
360
923
  const activeUseHandle = await createActiveUseHandle(correlation);
361
924
  try {
362
- const result = await fn();
925
+ const result = await fn(activeUseHandle);
363
926
  await activeUseHandle.dispose("completed");
364
927
  return result;
365
928
  } catch (error) {
366
- await activeUseHandle.dispose("failed").catch((cleanupError) => {
929
+ await activeUseHandle.dispose(error instanceof ToolVmSshOperationStaleError && error.reason === "ssh-command-timed-out" ? "timed-out" : "failed").catch((cleanupError) => {
367
930
  writeSandboxBackendLog(`failed to end active use after operation failure for zone '${options.zoneId}' lease '${options.lease.leaseId}': ${formatUnknownError(cleanupError)}`);
368
931
  });
932
+ if (error instanceof ToolVmSshOperationStaleError) await options.markCachedLeaseStale(error.reason, error);
369
933
  throw error;
370
934
  }
371
935
  };
372
936
  const boundRunRemoteShellScript = async (shellParams) => await runWithActiveUse({
373
937
  sessionKey: options.sessionKey,
374
938
  toolName: "fs-bridge"
375
- }, async () => await options.runRemoteShellScript({
376
- ...shellParams.allowFailure !== void 0 ? { allowFailure: shellParams.allowFailure } : {},
377
- script: buildShellScriptWithArgs(shellParams.script, shellParams.args),
378
- ...shellParams.signal !== void 0 ? { signal: shellParams.signal } : {},
379
- ssh: options.lease.ssh,
380
- ...shellParams.stdin !== void 0 ? { stdin: shellParams.stdin } : {}
939
+ }, async (activeUseHandle) => await runToolVmSshOperationWithGuard({
940
+ healthEvent: {
941
+ agentId: options.lease.agentId,
942
+ leaseId: options.lease.leaseId,
943
+ operation: "file-bridge",
944
+ publish: options.publishHealthEvent,
945
+ zoneId: options.zoneId
946
+ },
947
+ operation: async (signal) => {
948
+ const operationSignal = mergedAbortSignals([
949
+ shellParams.signal,
950
+ activeUseHandle.signal,
951
+ signal
952
+ ]);
953
+ return await options.runRemoteShellScript({
954
+ ...shellParams.allowFailure !== void 0 ? { allowFailure: shellParams.allowFailure } : {},
955
+ script: buildShellScriptWithArgs(shellParams.script, shellParams.args),
956
+ ...operationSignal === void 0 ? {} : { signal: operationSignal },
957
+ ssh: options.lease.ssh,
958
+ ...shellParams.stdin !== void 0 ? { stdin: shellParams.stdin } : {}
959
+ });
960
+ },
961
+ operationName: "fs-bridge",
962
+ report: (report) => {
963
+ activeUseHandle.report(report);
964
+ },
965
+ timeoutMs: 3e4
381
966
  }));
382
967
  const disposeInnerFinalizeToken = async (token) => {
383
968
  if (isDisposableFinalizeToken(token)) await token.dispose();
@@ -400,7 +985,7 @@ function createSandboxBackendHandle(options) {
400
985
  };
401
986
  const createFsBridge = options.createFsBridgeBuilder?.({
402
987
  remoteAgentWorkspaceDir: options.lease.workdir,
403
- remoteWorkspaceDir: options.lease.workdir,
988
+ remoteWorkspaceDir: options.effectiveGuestCwd,
404
989
  runRemoteShellScript: boundRunRemoteShellScript
405
990
  });
406
991
  return {
@@ -411,7 +996,7 @@ function createSandboxBackendHandle(options) {
411
996
  id: "gondolin",
412
997
  runtimeId: options.lease.leaseId,
413
998
  runtimeLabel: options.lease.leaseId,
414
- workdir: options.lease.workdir,
999
+ workdir: options.effectiveGuestCwd,
415
1000
  buildExecSpec: async (execParams) => {
416
1001
  const activeUseHandle = await createActiveUseHandle({
417
1002
  sessionKey: options.sessionKey,
@@ -423,7 +1008,7 @@ function createSandboxBackendHandle(options) {
423
1008
  env: execParams.env,
424
1009
  ssh: options.lease.ssh,
425
1010
  usePty: execParams.usePty,
426
- workdir: execParams.workdir ?? options.lease.workdir
1011
+ workdir: execParams.workdir ?? options.effectiveGuestCwd
427
1012
  });
428
1013
  return {
429
1014
  ...execSpec,
@@ -441,7 +1026,23 @@ function createSandboxBackendHandle(options) {
441
1026
  },
442
1027
  finalizeExec: async (finalizeParams) => {
443
1028
  if (isActiveUseFinalizeToken(finalizeParams.token)) {
1029
+ if (finalizeParams.timedOut) finalizeParams.token.activeUseHandle.report({
1030
+ observedAtMs: Date.now(),
1031
+ phase: "failed",
1032
+ ssh: { failure: {
1033
+ kind: "ssh-command-timed-out",
1034
+ message: "exec command timed out."
1035
+ } }
1036
+ });
444
1037
  await endActiveUseFinalizeToken(finalizeParams.token, activeUseOutcomeForFinalizeParams(finalizeParams));
1038
+ publishFinalizeToolVmSshHealthEvent({
1039
+ agentId: options.lease.agentId,
1040
+ leaseId: options.lease.leaseId,
1041
+ publishHealthEvent: options.publishHealthEvent,
1042
+ timedOut: finalizeParams.timedOut,
1043
+ zoneId: options.zoneId
1044
+ });
1045
+ if (finalizeParams.timedOut) await options.markCachedLeaseStale("ssh-command-timed-out", void 0);
445
1046
  return;
446
1047
  }
447
1048
  await disposeInnerFinalizeToken(finalizeParams.token);
@@ -449,9 +1050,24 @@ function createSandboxBackendHandle(options) {
449
1050
  runShellCommand: async (commandParams) => await runWithActiveUse({
450
1051
  sessionKey: options.sessionKey,
451
1052
  toolName: "runShellCommand"
452
- }, async () => await options.runRemoteShellScript({
453
- script: commandParams.script,
454
- ssh: options.lease.ssh
1053
+ }, async (activeUseHandle) => await runToolVmSshOperationWithGuard({
1054
+ healthEvent: {
1055
+ agentId: options.lease.agentId,
1056
+ leaseId: options.lease.leaseId,
1057
+ operation: "command",
1058
+ publish: options.publishHealthEvent,
1059
+ zoneId: options.zoneId
1060
+ },
1061
+ operation: async (signal) => await options.runRemoteShellScript({
1062
+ script: commandParams.script,
1063
+ signal: mergedAbortSignal(activeUseHandle.signal, signal),
1064
+ ssh: options.lease.ssh
1065
+ }),
1066
+ operationName: "runShellCommand",
1067
+ report: (report) => {
1068
+ activeUseHandle.report(report);
1069
+ },
1070
+ timeoutMs: 3e4
455
1071
  }))
456
1072
  };
457
1073
  }
@@ -481,10 +1097,20 @@ function createGondolinSandboxBackendManager(options, dependencies) {
481
1097
  }
482
1098
  //#endregion
483
1099
  //#region src/gondolin-plugin-config.ts
1100
+ function isObjectRecord$1(value) {
1101
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1102
+ }
484
1103
  function resolveGondolinPluginConfig(config) {
485
1104
  if (typeof config.controllerUrl !== "string" || typeof config.zoneId !== "string") throw new Error("Gondolin plugin config requires controllerUrl and zoneId.");
1105
+ const rawGatewayControlLinkMonitor = config.gatewayControlLinkMonitor;
1106
+ const gatewayControlLinkMonitor = isObjectRecord$1(rawGatewayControlLinkMonitor) ? {
1107
+ baseIntervalMs: typeof rawGatewayControlLinkMonitor.baseIntervalMs === "number" ? rawGatewayControlLinkMonitor.baseIntervalMs : 1e4,
1108
+ enabled: typeof rawGatewayControlLinkMonitor.enabled === "boolean" ? rawGatewayControlLinkMonitor.enabled : true,
1109
+ maxIntervalMs: typeof rawGatewayControlLinkMonitor.maxIntervalMs === "number" ? rawGatewayControlLinkMonitor.maxIntervalMs : 12e4
1110
+ } : void 0;
486
1111
  return {
487
1112
  controllerUrl: config.controllerUrl,
1113
+ ...gatewayControlLinkMonitor ? { gatewayControlLinkMonitor } : {},
488
1114
  ...typeof config.profileId === "string" ? { profileId: config.profileId } : {},
489
1115
  ...typeof config.zoneGitToken === "string" ? { zoneGitToken: config.zoneGitToken } : {},
490
1116
  ...typeof config.zoneGitTokenEnv === "string" ? { zoneGitTokenEnv: config.zoneGitTokenEnv } : {},
@@ -492,6 +1118,119 @@ function resolveGondolinPluginConfig(config) {
492
1118
  };
493
1119
  }
494
1120
  //#endregion
1121
+ //#region src/gateway-control-link-monitor.ts
1122
+ function defaultWriteLog(message) {
1123
+ process.stderr.write(`[gateway-control-link-monitor] ${message}\n`);
1124
+ }
1125
+ function joinUrl(baseUrl, path) {
1126
+ return `${baseUrl.replace(/\/+$/, "")}${path}`;
1127
+ }
1128
+ function nextIntervalMs(options) {
1129
+ const multiplier = 2 ** Math.min(options.consecutiveFailureCount, 8);
1130
+ return Math.min(options.maxIntervalMs, options.baseIntervalMs * multiplier);
1131
+ }
1132
+ function createGatewayControlLinkMonitor(options) {
1133
+ const fetchImpl = options.fetchImpl ?? fetch;
1134
+ const setTimeoutImpl = options.setTimeoutImpl ?? setTimeout;
1135
+ const clearTimeoutImpl = options.clearTimeoutImpl ?? clearTimeout;
1136
+ const writeLog = options.writeLog ?? defaultWriteLog;
1137
+ let consecutiveFailureCount = 0;
1138
+ let stopped = true;
1139
+ let timer;
1140
+ const publish = async (event) => {
1141
+ const response = await fetchControllerWithPolicy({
1142
+ fetchImpl,
1143
+ input: joinUrl(options.controllerUrl, `/zones/${encodeURIComponent(options.zoneId)}/health-events`),
1144
+ init: {
1145
+ body: JSON.stringify(event),
1146
+ headers: { "content-type": "application/json" },
1147
+ method: "POST"
1148
+ },
1149
+ operation: "health-event-publish"
1150
+ });
1151
+ if (!response.ok) {
1152
+ await response.text().catch(() => void 0);
1153
+ throw new Error(`health event publish returned HTTP ${String(response.status)}`);
1154
+ }
1155
+ await response.text().catch(() => void 0);
1156
+ };
1157
+ const scheduleNext = () => {
1158
+ if (stopped) return;
1159
+ if (timer) return;
1160
+ timer = setTimeoutImpl(() => {
1161
+ timer = void 0;
1162
+ tick().finally(scheduleNext);
1163
+ }, nextIntervalMs({
1164
+ baseIntervalMs: options.baseIntervalMs,
1165
+ consecutiveFailureCount,
1166
+ maxIntervalMs: options.maxIntervalMs
1167
+ }));
1168
+ timer.unref?.();
1169
+ };
1170
+ const tick = async () => {
1171
+ const startedAtMs = options.now();
1172
+ let event;
1173
+ try {
1174
+ const response = await fetchControllerWithPolicy({
1175
+ fetchImpl,
1176
+ input: joinUrl(options.controllerUrl, gatewayControlLinkHealthPins.path),
1177
+ init: { method: "GET" },
1178
+ operation: "controller-health"
1179
+ });
1180
+ const ok = response.ok;
1181
+ await response.text().catch(() => void 0);
1182
+ consecutiveFailureCount = ok ? 0 : consecutiveFailureCount + 1;
1183
+ event = {
1184
+ controllerHost: gatewayControlLinkHealthPins.controllerHost,
1185
+ controllerPort: gatewayControlLinkHealthPins.controllerPort,
1186
+ elapsedMs: options.now() - startedAtMs,
1187
+ kind: "gateway-control-link",
1188
+ observedAtMs: options.now(),
1189
+ operation: gatewayControlLinkHealthPins.operation,
1190
+ path: gatewayControlLinkHealthPins.path,
1191
+ result: ok ? "ok" : "failed",
1192
+ zoneId: options.zoneId
1193
+ };
1194
+ } catch (error) {
1195
+ consecutiveFailureCount += 1;
1196
+ event = {
1197
+ controllerHost: gatewayControlLinkHealthPins.controllerHost,
1198
+ controllerPort: gatewayControlLinkHealthPins.controllerPort,
1199
+ elapsedMs: options.now() - startedAtMs,
1200
+ kind: "gateway-control-link",
1201
+ observedAtMs: options.now(),
1202
+ operation: gatewayControlLinkHealthPins.operation,
1203
+ path: gatewayControlLinkHealthPins.path,
1204
+ result: error instanceof ControllerRequestPolicyTransportError && error.code === "controller-request-timeout" ? "timeout" : "failed",
1205
+ zoneId: options.zoneId
1206
+ };
1207
+ writeLog(`gateway-control-link fetch failed operation=controller-health elapsedMs=${String(event.elapsedMs)} errorCode=${error instanceof ControllerRequestPolicyTransportError ? error.code : "controller-request-failed"}`);
1208
+ }
1209
+ try {
1210
+ await publish(event);
1211
+ } catch (error) {
1212
+ writeLog(`gateway-control-link publish failed operation=health-event-publish elapsedMs=${String(event.elapsedMs)} errorCode=${error instanceof ControllerRequestPolicyTransportError ? error.code : "health-event-publish-failed"} message=${error instanceof Error ? error.message : String(error)}`);
1213
+ }
1214
+ };
1215
+ return {
1216
+ consecutiveFailureCount: () => consecutiveFailureCount,
1217
+ noteFailureForTest: () => {
1218
+ consecutiveFailureCount += 1;
1219
+ },
1220
+ start: () => {
1221
+ stopped = false;
1222
+ scheduleNext();
1223
+ },
1224
+ stop: () => {
1225
+ stopped = true;
1226
+ if (!timer) return;
1227
+ clearTimeoutImpl(timer);
1228
+ timer = void 0;
1229
+ },
1230
+ tick
1231
+ };
1232
+ }
1233
+ //#endregion
495
1234
  //#region src/openclaw-backend-dependencies.ts
496
1235
  const OPENCLAW_SSH_SESSION_SCRATCH_ROOT = "/work";
497
1236
  function createBackendDeps(ssh) {
@@ -670,13 +1409,18 @@ function registerZoneGitTool(options) {
670
1409
  },
671
1410
  execute: async (_toolCallId, input) => {
672
1411
  const expectedHead = readExpectedHead(input);
673
- const response = await (options.fetchImpl ?? fetch)(buildControllerUrl(options.controllerUrl, options.zoneId), {
674
- body: JSON.stringify({ expectedHead }),
675
- headers: {
676
- "content-type": "application/json",
677
- ...options.zoneGitToken ? { [zoneGitCapabilityHeader]: options.zoneGitToken } : {}
1412
+ const response = await fetchControllerWithPolicy({
1413
+ fetchImpl: options.fetchImpl ?? fetch,
1414
+ input: buildControllerUrl(options.controllerUrl, options.zoneId),
1415
+ init: {
1416
+ body: JSON.stringify({ expectedHead }),
1417
+ headers: {
1418
+ "content-type": "application/json",
1419
+ ...options.zoneGitToken ? { [zoneGitCapabilityHeader]: options.zoneGitToken } : {}
1420
+ },
1421
+ method: "POST"
678
1422
  },
679
- method: "POST"
1423
+ operation: "zone-git-push"
680
1424
  });
681
1425
  const responseText = await readResponseText(response);
682
1426
  if (!response.ok) throw new Error(`zone_git_push failed: ${response.status} ${responseText.slice(0, 500)}`);
@@ -693,22 +1437,8 @@ function registerZoneGitTool(options) {
693
1437
  }
694
1438
  //#endregion
695
1439
  //#region src/openclaw-plugin-registration.ts
696
- const runtimeStatusPublishMaxAttempts = 30;
697
- const runtimeStatusPublishRetryDelayMs = 1e3;
698
- function sleep(ms) {
699
- return new Promise((resolve) => {
700
- setTimeout(resolve, ms);
701
- });
702
- }
703
- async function publishRuntimeStatusWithRetry(options) {
704
- const leaseClient = createLeaseClient({ controllerUrl: options.controllerUrl });
705
- for (let attemptIndex = 0; attemptIndex < runtimeStatusPublishMaxAttempts; attemptIndex += 1) try {
706
- await leaseClient.publishOpenClawRuntimeStatus?.(options.report);
707
- return;
708
- } catch (error) {
709
- if (attemptIndex === runtimeStatusPublishMaxAttempts - 1) throw error;
710
- await sleep(runtimeStatusPublishRetryDelayMs);
711
- }
1440
+ async function publishRuntimeStatus(options) {
1441
+ await createLeaseClient({ controllerUrl: options.controllerUrl }).publishOpenClawRuntimeStatus?.(options.report);
712
1442
  }
713
1443
  const plugin = {
714
1444
  id: "gondolin",
@@ -729,6 +1459,13 @@ const plugin = {
729
1459
  zoneId: pluginConfig.zoneId
730
1460
  });
731
1461
  if (api.registrationMode !== "full") return;
1462
+ if (pluginConfig.gatewayControlLinkMonitor?.enabled) createGatewayControlLinkMonitor({
1463
+ baseIntervalMs: pluginConfig.gatewayControlLinkMonitor.baseIntervalMs,
1464
+ controllerUrl: pluginConfig.controllerUrl,
1465
+ maxIntervalMs: pluginConfig.gatewayControlLinkMonitor.maxIntervalMs,
1466
+ now: () => Date.now(),
1467
+ zoneId: pluginConfig.zoneId
1468
+ }).start();
732
1469
  const buildRuntimeStatus = () => {
733
1470
  const runtimeConfig = api.runtime?.config?.current?.() ?? api.config;
734
1471
  return runtimeConfig ? buildOpenClawRuntimeStatusReport({
@@ -737,7 +1474,7 @@ const plugin = {
737
1474
  }) : void 0;
738
1475
  };
739
1476
  const initialRuntimeStatus = buildRuntimeStatus();
740
- if (initialRuntimeStatus) publishRuntimeStatusWithRetry({
1477
+ if (initialRuntimeStatus) publishRuntimeStatus({
741
1478
  controllerUrl: pluginConfig.controllerUrl,
742
1479
  report: initialRuntimeStatus
743
1480
  }).catch((error) => {
@@ -759,6 +1496,7 @@ const plugin = {
759
1496
  sdkRaw.registerSandboxBackend("gondolin", {
760
1497
  factory: createGondolinSandboxBackendFactory({
761
1498
  ...pluginConfig,
1499
+ openClawRuntimeConfigProvider: () => api.runtime?.config?.current?.() ?? api.config,
762
1500
  openClawRuntimeStatusProvider: buildRuntimeStatus
763
1501
  }, backendDependencies),
764
1502
  manager: createGondolinSandboxBackendManager(pluginConfig, backendDependencies)
@@ -773,6 +1511,6 @@ const plugin = {
773
1511
  //#region src/index.ts
774
1512
  const OPENCLAW_GONDOLIN_PLUGIN_PACKAGE_NAME = "@agent-vm/openclaw-agent-vm-plugin";
775
1513
  //#endregion
776
- export { ControllerLeaseRequestError, OPENCLAW_DEFAULT_AGENT_ID, OPENCLAW_GONDOLIN_LEASE_SCOPE_GUIDANCE, OPENCLAW_GONDOLIN_PLUGIN_PACKAGE_NAME, OPENCLAW_GONDOLIN_SANDBOX_REQUIREMENTS, OPENCLAW_SSH_SESSION_SCRATCH_ROOT, buildOpenClawRuntimeStatusReport, createBackendDeps, createGondolinSandboxBackendFactory, createGondolinSandboxBackendManager, createLeaseClient, plugin as default, effectiveOpenClawGondolinSandboxValue, findOpenClawGondolinSandboxMismatch, formatOpenClawGondolinRequirementFieldPath, formatOpenClawGondolinRequirementFindingId, formatOpenClawGondolinRequirementHint, isOpenClawAgentId, isOpenClawAgentSessionKey, normalizeOpenClawAgentId, resolveGondolinPluginConfig, resolveOpenClawAgentIdFromSessionKey, snapshotOpenClawGondolinSandboxConfig };
1514
+ export { ControllerLeaseRequestError, ControllerRequestPolicyTransportError, OPENCLAW_DEFAULT_AGENT_ID, OPENCLAW_GONDOLIN_LEASE_SCOPE_GUIDANCE, OPENCLAW_GONDOLIN_PLUGIN_PACKAGE_NAME, OPENCLAW_GONDOLIN_SANDBOX_REQUIREMENTS, OPENCLAW_SSH_SESSION_SCRATCH_ROOT, OpenClawAgentIdError, buildOpenClawRuntimeStatusReport, createBackendDeps, createGatewayControlLinkMonitor, createGondolinSandboxBackendFactory, createGondolinSandboxBackendManager, createLeaseClient, plugin as default, drainControllerResponseBody, effectiveOpenClawGondolinSandboxValue, fetchControllerWithPolicy, findOpenClawGondolinSandboxMismatch, formatOpenClawGondolinRequirementFieldPath, formatOpenClawGondolinRequirementFindingId, formatOpenClawGondolinRequirementHint, isOpenClawAgentId, isOpenClawAgentSessionKey, normalizeOpenClawAgentId, resolveGondolinPluginConfig, resolveOpenClawAgentIdFromSessionKey, snapshotOpenClawGondolinSandboxConfig };
777
1515
 
778
1516
  //# sourceMappingURL=index.js.map