@vm0/runner 2.8.4 → 2.10.0
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/index.js +406 -2788
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -12,6 +12,15 @@ import { dirname, join } from "path";
|
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
import fs from "fs";
|
|
14
14
|
import yaml from "yaml";
|
|
15
|
+
var SANDBOX_DEFAULTS = {
|
|
16
|
+
max_concurrent: 1,
|
|
17
|
+
vcpu: 2,
|
|
18
|
+
memory_mb: 2048,
|
|
19
|
+
poll_interval_ms: 5e3
|
|
20
|
+
};
|
|
21
|
+
var PROXY_DEFAULTS = {
|
|
22
|
+
port: 8080
|
|
23
|
+
};
|
|
15
24
|
var runnerConfigSchema = z.object({
|
|
16
25
|
name: z.string().min(1, "Name is required"),
|
|
17
26
|
group: z.string().regex(
|
|
@@ -23,58 +32,45 @@ var runnerConfigSchema = z.object({
|
|
|
23
32
|
token: z.string().min(1, "Server token is required")
|
|
24
33
|
}),
|
|
25
34
|
sandbox: z.object({
|
|
26
|
-
max_concurrent: z.number().int().min(1).default(
|
|
27
|
-
vcpu: z.number().int().min(1).default(
|
|
28
|
-
memory_mb: z.number().int().min(128).default(
|
|
29
|
-
poll_interval_ms: z.number().int().min(1e3).default(
|
|
30
|
-
}).default(
|
|
31
|
-
max_concurrent: 1,
|
|
32
|
-
vcpu: 2,
|
|
33
|
-
memory_mb: 2048,
|
|
34
|
-
poll_interval_ms: 5e3
|
|
35
|
-
}),
|
|
35
|
+
max_concurrent: z.number().int().min(1).default(SANDBOX_DEFAULTS.max_concurrent),
|
|
36
|
+
vcpu: z.number().int().min(1).default(SANDBOX_DEFAULTS.vcpu),
|
|
37
|
+
memory_mb: z.number().int().min(128).default(SANDBOX_DEFAULTS.memory_mb),
|
|
38
|
+
poll_interval_ms: z.number().int().min(1e3).default(SANDBOX_DEFAULTS.poll_interval_ms)
|
|
39
|
+
}).default(SANDBOX_DEFAULTS),
|
|
36
40
|
firecracker: z.object({
|
|
37
41
|
binary: z.string().min(1, "Firecracker binary path is required"),
|
|
38
42
|
kernel: z.string().min(1, "Kernel path is required"),
|
|
39
43
|
rootfs: z.string().min(1, "Rootfs path is required")
|
|
40
44
|
}),
|
|
41
45
|
proxy: z.object({
|
|
42
|
-
port: z.number().int().min(1024).max(65535).default(
|
|
43
|
-
}).default(
|
|
44
|
-
port: 8080
|
|
45
|
-
})
|
|
46
|
+
port: z.number().int().min(1024).max(65535).default(PROXY_DEFAULTS.port)
|
|
47
|
+
}).default(PROXY_DEFAULTS)
|
|
46
48
|
});
|
|
49
|
+
var DEBUG_SERVER_DEFAULTS = {
|
|
50
|
+
url: "http://localhost:3000",
|
|
51
|
+
token: "debug-token"
|
|
52
|
+
};
|
|
47
53
|
var debugConfigSchema = z.object({
|
|
48
54
|
name: z.string().default("debug-runner"),
|
|
49
55
|
group: z.string().default("debug/local"),
|
|
50
56
|
server: z.object({
|
|
51
|
-
url: z.string().url().default(
|
|
52
|
-
token: z.string().default(
|
|
53
|
-
}).default(
|
|
54
|
-
url: "http://localhost:3000",
|
|
55
|
-
token: "debug-token"
|
|
56
|
-
}),
|
|
57
|
+
url: z.string().url().default(DEBUG_SERVER_DEFAULTS.url),
|
|
58
|
+
token: z.string().default(DEBUG_SERVER_DEFAULTS.token)
|
|
59
|
+
}).default(DEBUG_SERVER_DEFAULTS),
|
|
57
60
|
sandbox: z.object({
|
|
58
|
-
max_concurrent: z.number().int().min(1).default(
|
|
59
|
-
vcpu: z.number().int().min(1).default(
|
|
60
|
-
memory_mb: z.number().int().min(128).default(
|
|
61
|
-
poll_interval_ms: z.number().int().min(1e3).default(
|
|
62
|
-
}).default(
|
|
63
|
-
max_concurrent: 1,
|
|
64
|
-
vcpu: 2,
|
|
65
|
-
memory_mb: 2048,
|
|
66
|
-
poll_interval_ms: 5e3
|
|
67
|
-
}),
|
|
61
|
+
max_concurrent: z.number().int().min(1).default(SANDBOX_DEFAULTS.max_concurrent),
|
|
62
|
+
vcpu: z.number().int().min(1).default(SANDBOX_DEFAULTS.vcpu),
|
|
63
|
+
memory_mb: z.number().int().min(128).default(SANDBOX_DEFAULTS.memory_mb),
|
|
64
|
+
poll_interval_ms: z.number().int().min(1e3).default(SANDBOX_DEFAULTS.poll_interval_ms)
|
|
65
|
+
}).default(SANDBOX_DEFAULTS),
|
|
68
66
|
firecracker: z.object({
|
|
69
67
|
binary: z.string().min(1, "Firecracker binary path is required"),
|
|
70
68
|
kernel: z.string().min(1, "Kernel path is required"),
|
|
71
69
|
rootfs: z.string().min(1, "Rootfs path is required")
|
|
72
70
|
}),
|
|
73
71
|
proxy: z.object({
|
|
74
|
-
port: z.number().int().min(1024).max(65535).default(
|
|
75
|
-
}).default(
|
|
76
|
-
port: 8080
|
|
77
|
-
})
|
|
72
|
+
port: z.number().int().min(1024).max(65535).default(PROXY_DEFAULTS.port)
|
|
73
|
+
}).default(PROXY_DEFAULTS)
|
|
78
74
|
});
|
|
79
75
|
function loadDebugConfig(configPath) {
|
|
80
76
|
if (!fs.existsSync(configPath)) {
|
|
@@ -192,7 +188,6 @@ async function completeJob(apiUrl, context, exitCode, error) {
|
|
|
192
188
|
|
|
193
189
|
// src/lib/executor.ts
|
|
194
190
|
import path4 from "path";
|
|
195
|
-
import fs6 from "fs";
|
|
196
191
|
|
|
197
192
|
// src/lib/firecracker/vm.ts
|
|
198
193
|
import { execSync as execSync2, spawn } from "child_process";
|
|
@@ -5020,15 +5015,33 @@ var volumeConfigSchema = z5.object({
|
|
|
5020
5015
|
version: z5.string().min(1, "Volume version is required")
|
|
5021
5016
|
});
|
|
5022
5017
|
var SUPPORTED_APPS = ["github"];
|
|
5023
|
-
var
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
(
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5018
|
+
var SUPPORTED_APP_TAGS = ["latest", "dev"];
|
|
5019
|
+
var APP_NAME_REGEX = /^[a-z0-9-]+$/;
|
|
5020
|
+
var appStringSchema = z5.string().superRefine((val, ctx) => {
|
|
5021
|
+
const [appName, tag] = val.split(":");
|
|
5022
|
+
if (!appName || !APP_NAME_REGEX.test(appName)) {
|
|
5023
|
+
ctx.addIssue({
|
|
5024
|
+
code: z5.ZodIssueCode.custom,
|
|
5025
|
+
message: "App name must contain only lowercase letters, numbers, and hyphens"
|
|
5026
|
+
});
|
|
5027
|
+
return;
|
|
5028
|
+
}
|
|
5029
|
+
if (!SUPPORTED_APPS.includes(appName)) {
|
|
5030
|
+
ctx.addIssue({
|
|
5031
|
+
code: z5.ZodIssueCode.custom,
|
|
5032
|
+
message: `Invalid app: "${appName}". Supported apps: ${SUPPORTED_APPS.join(", ")}`
|
|
5033
|
+
});
|
|
5034
|
+
return;
|
|
5035
|
+
}
|
|
5036
|
+
if (tag !== void 0 && !SUPPORTED_APP_TAGS.includes(tag)) {
|
|
5037
|
+
ctx.addIssue({
|
|
5038
|
+
code: z5.ZodIssueCode.custom,
|
|
5039
|
+
message: `Invalid app tag: "${tag}". Supported tags: ${SUPPORTED_APP_TAGS.join(", ")}`
|
|
5040
|
+
});
|
|
5041
|
+
}
|
|
5042
|
+
});
|
|
5043
|
+
var nonEmptyStringArraySchema = z5.array(
|
|
5044
|
+
z5.string().min(1, "Array entries cannot be empty strings")
|
|
5032
5045
|
);
|
|
5033
5046
|
var agentDefinitionSchema = z5.object({
|
|
5034
5047
|
description: z5.string().optional(),
|
|
@@ -5053,7 +5066,7 @@ var agentDefinitionSchema = z5.object({
|
|
|
5053
5066
|
* Path to instructions file (e.g., AGENTS.md).
|
|
5054
5067
|
* Auto-uploaded as volume and mounted at /home/user/.claude/CLAUDE.md
|
|
5055
5068
|
*/
|
|
5056
|
-
instructions: z5.string().optional(),
|
|
5069
|
+
instructions: z5.string().min(1, "Instructions path cannot be empty").optional(),
|
|
5057
5070
|
/**
|
|
5058
5071
|
* Array of GitHub tree URLs for agent skills.
|
|
5059
5072
|
* Each skill is auto-downloaded and mounted at /home/user/.claude/skills/{skillName}/
|
|
@@ -5074,7 +5087,17 @@ var agentDefinitionSchema = z5.object({
|
|
|
5074
5087
|
* Requires experimental_runner to be configured.
|
|
5075
5088
|
* When enabled, filters outbound traffic by domain/IP rules.
|
|
5076
5089
|
*/
|
|
5077
|
-
experimental_firewall: experimentalFirewallSchema.optional()
|
|
5090
|
+
experimental_firewall: experimentalFirewallSchema.optional(),
|
|
5091
|
+
/**
|
|
5092
|
+
* Array of secret names to inject from the scope's secret store.
|
|
5093
|
+
* Each entry must be a non-empty string.
|
|
5094
|
+
*/
|
|
5095
|
+
experimental_secrets: nonEmptyStringArraySchema.optional(),
|
|
5096
|
+
/**
|
|
5097
|
+
* Array of variable names to inject from the scope's variable store.
|
|
5098
|
+
* Each entry must be a non-empty string.
|
|
5099
|
+
*/
|
|
5100
|
+
experimental_vars: nonEmptyStringArraySchema.optional()
|
|
5078
5101
|
});
|
|
5079
5102
|
var agentComposeContentSchema = z5.object({
|
|
5080
5103
|
version: z5.string().min(1, "Version is required"),
|
|
@@ -7095,2766 +7118,266 @@ var publicVolumeDownloadContract = c15.router({
|
|
|
7095
7118
|
}
|
|
7096
7119
|
});
|
|
7097
7120
|
|
|
7098
|
-
// ../../packages/core/src/sandbox/scripts/
|
|
7099
|
-
var INIT_SCRIPT = `#!/usr/bin/env python3
|
|
7100
|
-
"""
|
|
7101
|
-
|
|
7121
|
+
// ../../packages/core/src/sandbox/scripts/dist/bundled.ts
|
|
7122
|
+
var RUN_AGENT_SCRIPT = '#!/usr/bin/env node\n\n// src/sandbox/scripts/src/run-agent.ts\nimport * as fs7 from "fs";\nimport { spawn, execSync as execSync4 } from "child_process";\nimport * as readline from "readline";\n\n// src/sandbox/scripts/src/lib/common.ts\nimport * as fs from "fs";\nvar RUN_ID = process.env.VM0_RUN_ID ?? "";\nvar API_URL = process.env.VM0_API_URL ?? "";\nvar API_TOKEN = process.env.VM0_API_TOKEN ?? "";\nvar PROMPT = process.env.VM0_PROMPT ?? "";\nvar VERCEL_BYPASS = process.env.VERCEL_PROTECTION_BYPASS ?? "";\nvar RESUME_SESSION_ID = process.env.VM0_RESUME_SESSION_ID ?? "";\nvar CLI_AGENT_TYPE = process.env.CLI_AGENT_TYPE ?? "claude-code";\nvar OPENAI_MODEL = process.env.OPENAI_MODEL ?? "";\nvar WORKING_DIR = process.env.VM0_WORKING_DIR ?? "";\nvar ARTIFACT_DRIVER = process.env.VM0_ARTIFACT_DRIVER ?? "";\nvar ARTIFACT_MOUNT_PATH = process.env.VM0_ARTIFACT_MOUNT_PATH ?? "";\nvar ARTIFACT_VOLUME_NAME = process.env.VM0_ARTIFACT_VOLUME_NAME ?? "";\nvar ARTIFACT_VERSION_ID = process.env.VM0_ARTIFACT_VERSION_ID ?? "";\nvar WEBHOOK_URL = `${API_URL}/api/webhooks/agent/events`;\nvar CHECKPOINT_URL = `${API_URL}/api/webhooks/agent/checkpoints`;\nvar COMPLETE_URL = `${API_URL}/api/webhooks/agent/complete`;\nvar HEARTBEAT_URL = `${API_URL}/api/webhooks/agent/heartbeat`;\nvar TELEMETRY_URL = `${API_URL}/api/webhooks/agent/telemetry`;\nvar PROXY_URL = `${API_URL}/api/webhooks/agent/proxy`;\nvar STORAGE_PREPARE_URL = `${API_URL}/api/webhooks/agent/storages/prepare`;\nvar STORAGE_COMMIT_URL = `${API_URL}/api/webhooks/agent/storages/commit`;\nvar HEARTBEAT_INTERVAL = 60;\nvar TELEMETRY_INTERVAL = 30;\nvar HTTP_CONNECT_TIMEOUT = 10;\nvar HTTP_MAX_TIME = 30;\nvar HTTP_MAX_TIME_UPLOAD = 60;\nvar HTTP_MAX_RETRIES = 3;\nvar SESSION_ID_FILE = `/tmp/vm0-session-${RUN_ID}.txt`;\nvar SESSION_HISTORY_PATH_FILE = `/tmp/vm0-session-history-${RUN_ID}.txt`;\nvar EVENT_ERROR_FLAG = `/tmp/vm0-event-error-${RUN_ID}`;\nvar SYSTEM_LOG_FILE = `/tmp/vm0-main-${RUN_ID}.log`;\nvar AGENT_LOG_FILE = `/tmp/vm0-agent-${RUN_ID}.log`;\nvar METRICS_LOG_FILE = `/tmp/vm0-metrics-${RUN_ID}.jsonl`;\nvar NETWORK_LOG_FILE = `/tmp/vm0-network-${RUN_ID}.jsonl`;\nvar TELEMETRY_LOG_POS_FILE = `/tmp/vm0-telemetry-log-pos-${RUN_ID}.txt`;\nvar TELEMETRY_METRICS_POS_FILE = `/tmp/vm0-telemetry-metrics-pos-${RUN_ID}.txt`;\nvar TELEMETRY_NETWORK_POS_FILE = `/tmp/vm0-telemetry-network-pos-${RUN_ID}.txt`;\nvar TELEMETRY_SANDBOX_OPS_POS_FILE = `/tmp/vm0-telemetry-sandbox-ops-pos-${RUN_ID}.txt`;\nvar SANDBOX_OPS_LOG_FILE = `/tmp/vm0-sandbox-ops-${RUN_ID}.jsonl`;\nvar METRICS_INTERVAL = 5;\nfunction validateConfig() {\n if (!WORKING_DIR) {\n throw new Error("VM0_WORKING_DIR is required but not set");\n }\n return true;\n}\nfunction recordSandboxOp(actionType, durationMs, success, error) {\n const entry = {\n ts: (/* @__PURE__ */ new Date()).toISOString(),\n action_type: actionType,\n duration_ms: durationMs,\n success\n };\n if (error) {\n entry.error = error;\n }\n fs.appendFileSync(SANDBOX_OPS_LOG_FILE, JSON.stringify(entry) + "\\n");\n}\n\n// src/sandbox/scripts/src/lib/log.ts\nvar SCRIPT_NAME = process.env.LOG_SCRIPT_NAME ?? "run-agent";\nvar DEBUG_MODE = process.env.VM0_DEBUG === "1";\nfunction timestamp() {\n return (/* @__PURE__ */ new Date()).toISOString().replace(/\\.\\d{3}Z$/, "Z");\n}\nfunction logInfo(msg) {\n console.error(`[${timestamp()}] [INFO] [sandbox:${SCRIPT_NAME}] ${msg}`);\n}\nfunction logWarn(msg) {\n console.error(`[${timestamp()}] [WARN] [sandbox:${SCRIPT_NAME}] ${msg}`);\n}\nfunction logError(msg) {\n console.error(`[${timestamp()}] [ERROR] [sandbox:${SCRIPT_NAME}] ${msg}`);\n}\nfunction logDebug(msg) {\n if (DEBUG_MODE) {\n console.error(`[${timestamp()}] [DEBUG] [sandbox:${SCRIPT_NAME}] ${msg}`);\n }\n}\n\n// src/sandbox/scripts/src/lib/events.ts\nimport * as fs2 from "fs";\n\n// src/sandbox/scripts/src/lib/http-client.ts\nimport { execSync } from "child_process";\nfunction sleep(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\nasync function httpPostJson(url, data, maxRetries = HTTP_MAX_RETRIES) {\n const headers = {\n "Content-Type": "application/json",\n Authorization: `Bearer ${API_TOKEN}`\n };\n if (VERCEL_BYPASS) {\n headers["x-vercel-protection-bypass"] = VERCEL_BYPASS;\n }\n for (let attempt = 1; attempt <= maxRetries; attempt++) {\n logDebug(`HTTP POST attempt ${attempt}/${maxRetries} to ${url}`);\n try {\n const controller = new AbortController();\n const timeoutId = setTimeout(\n () => controller.abort(),\n HTTP_MAX_TIME * 1e3\n );\n const response = await fetch(url, {\n method: "POST",\n headers,\n body: JSON.stringify(data),\n signal: controller.signal\n });\n clearTimeout(timeoutId);\n if (response.ok) {\n const text = await response.text();\n if (text) {\n return JSON.parse(text);\n }\n return {};\n }\n logWarn(\n `HTTP POST failed (attempt ${attempt}/${maxRetries}): HTTP ${response.status}`\n );\n if (attempt < maxRetries) {\n await sleep(1e3);\n }\n } catch (error) {\n const errorMsg = error instanceof Error ? error.message : String(error);\n if (errorMsg.includes("abort")) {\n logWarn(`HTTP POST failed (attempt ${attempt}/${maxRetries}): Timeout`);\n } else {\n logWarn(\n `HTTP POST failed (attempt ${attempt}/${maxRetries}): ${errorMsg}`\n );\n }\n if (attempt < maxRetries) {\n await sleep(1e3);\n }\n }\n }\n logError(`HTTP POST failed after ${maxRetries} attempts to ${url}`);\n return null;\n}\nasync function httpPutPresigned(presignedUrl, filePath, contentType = "application/octet-stream", maxRetries = HTTP_MAX_RETRIES) {\n for (let attempt = 1; attempt <= maxRetries; attempt++) {\n logDebug(`HTTP PUT presigned attempt ${attempt}/${maxRetries}`);\n try {\n const curlCmd = [\n "curl",\n "-f",\n "-X",\n "PUT",\n "-H",\n `Content-Type: ${contentType}`,\n "--data-binary",\n `@${filePath}`,\n "--connect-timeout",\n String(HTTP_CONNECT_TIMEOUT),\n "--max-time",\n String(HTTP_MAX_TIME_UPLOAD),\n "--silent",\n `"${presignedUrl}"`\n ].join(" ");\n execSync(curlCmd, {\n timeout: HTTP_MAX_TIME_UPLOAD * 1e3,\n stdio: ["pipe", "pipe", "pipe"]\n });\n return true;\n } catch (error) {\n const errorMsg = error instanceof Error ? error.message : String(error);\n if (errorMsg.includes("ETIMEDOUT") || errorMsg.includes("timeout")) {\n logWarn(\n `HTTP PUT presigned failed (attempt ${attempt}/${maxRetries}): Timeout`\n );\n } else {\n logWarn(\n `HTTP PUT presigned failed (attempt ${attempt}/${maxRetries}): ${errorMsg}`\n );\n }\n if (attempt < maxRetries) {\n await sleep(1e3);\n }\n }\n }\n logError(`HTTP PUT presigned failed after ${maxRetries} attempts`);\n return false;\n}\n\n// src/sandbox/scripts/src/lib/secret-masker.ts\nvar MASK_PLACEHOLDER = "***";\nvar MIN_SECRET_LENGTH = 5;\nvar _masker = null;\nvar SecretMasker = class {\n patterns;\n /**\n * Initialize masker with secret values.\n *\n * @param secretValues - List of secret values to mask\n */\n constructor(secretValues) {\n this.patterns = /* @__PURE__ */ new Set();\n for (const secret of secretValues) {\n if (!secret || secret.length < MIN_SECRET_LENGTH) {\n continue;\n }\n this.patterns.add(secret);\n try {\n const b64 = Buffer.from(secret).toString("base64");\n if (b64.length >= MIN_SECRET_LENGTH) {\n this.patterns.add(b64);\n }\n } catch {\n }\n try {\n const urlEnc = encodeURIComponent(secret);\n if (urlEnc !== secret && urlEnc.length >= MIN_SECRET_LENGTH) {\n this.patterns.add(urlEnc);\n }\n } catch {\n }\n }\n }\n /**\n * Recursively mask all occurrences of secrets in the data.\n *\n * @param data - Data to mask (string, list, dict, or primitive)\n * @returns Masked data with the same structure\n */\n mask(data) {\n return this.deepMask(data);\n }\n deepMask(data) {\n if (typeof data === "string") {\n let result = data;\n for (const pattern of this.patterns) {\n result = result.split(pattern).join(MASK_PLACEHOLDER);\n }\n return result;\n }\n if (Array.isArray(data)) {\n return data.map((item) => this.deepMask(item));\n }\n if (data !== null && typeof data === "object") {\n const result = {};\n for (const [key, value] of Object.entries(\n data\n )) {\n result[key] = this.deepMask(value);\n }\n return result;\n }\n return data;\n }\n};\nfunction createMasker() {\n const secretValuesStr = process.env.VM0_SECRET_VALUES ?? "";\n if (!secretValuesStr) {\n return new SecretMasker([]);\n }\n const secretValues = [];\n for (const encodedValue of secretValuesStr.split(",")) {\n const trimmed = encodedValue.trim();\n if (trimmed) {\n try {\n const decoded = Buffer.from(trimmed, "base64").toString("utf-8");\n if (decoded) {\n secretValues.push(decoded);\n }\n } catch {\n }\n }\n }\n return new SecretMasker(secretValues);\n}\nfunction getMasker() {\n if (_masker === null) {\n _masker = createMasker();\n }\n return _masker;\n}\nfunction maskData(data) {\n return getMasker().mask(data);\n}\n\n// src/sandbox/scripts/src/lib/events.ts\nasync function sendEvent(event, sequenceNumber) {\n const eventType = event.type ?? "";\n const eventSubtype = event.subtype ?? "";\n let sessionId = null;\n if (CLI_AGENT_TYPE === "codex") {\n if (eventType === "thread.started") {\n sessionId = event.thread_id ?? "";\n }\n } else {\n if (eventType === "system" && eventSubtype === "init") {\n sessionId = event.session_id ?? "";\n }\n }\n if (sessionId && !fs2.existsSync(SESSION_ID_FILE)) {\n logInfo(`Captured session ID: ${sessionId}`);\n fs2.writeFileSync(SESSION_ID_FILE, sessionId);\n const homeDir = process.env.HOME ?? "/home/user";\n let sessionHistoryPath;\n if (CLI_AGENT_TYPE === "codex") {\n const codexHome = process.env.CODEX_HOME ?? `${homeDir}/.codex`;\n sessionHistoryPath = `CODEX_SEARCH:${codexHome}/sessions:${sessionId}`;\n } else {\n const projectName = WORKING_DIR.replace(/^\\//, "").replace(/\\//g, "-");\n sessionHistoryPath = `${homeDir}/.claude/projects/-${projectName}/${sessionId}.jsonl`;\n }\n fs2.writeFileSync(SESSION_HISTORY_PATH_FILE, sessionHistoryPath);\n logInfo(`Session history will be at: ${sessionHistoryPath}`);\n }\n const eventWithSequence = {\n ...event,\n sequenceNumber\n };\n const maskedEvent = maskData(eventWithSequence);\n const payload = {\n runId: RUN_ID,\n events: [maskedEvent]\n };\n const result = await httpPostJson(WEBHOOK_URL, payload);\n if (result === null) {\n logError("Failed to send event after retries");\n fs2.writeFileSync(EVENT_ERROR_FLAG, "1");\n return false;\n }\n return true;\n}\n\n// src/sandbox/scripts/src/lib/checkpoint.ts\nimport * as fs4 from "fs";\nimport * as path2 from "path";\n\n// src/sandbox/scripts/src/lib/direct-upload.ts\nimport * as fs3 from "fs";\nimport * as path from "path";\nimport * as crypto from "crypto";\nimport { execSync as execSync2 } from "child_process";\nfunction computeFileHash(filePath) {\n const hash = crypto.createHash("sha256");\n const buffer = fs3.readFileSync(filePath);\n hash.update(buffer);\n return hash.digest("hex");\n}\nfunction collectFileMetadata(dirPath) {\n const files = [];\n function walkDir(currentPath, relativePath) {\n const items = fs3.readdirSync(currentPath);\n for (const item of items) {\n if (item === ".git" || item === ".vm0") {\n continue;\n }\n const fullPath = path.join(currentPath, item);\n const relPath = relativePath ? path.join(relativePath, item) : item;\n const stat = fs3.statSync(fullPath);\n if (stat.isDirectory()) {\n walkDir(fullPath, relPath);\n } else if (stat.isFile()) {\n try {\n const fileHash = computeFileHash(fullPath);\n files.push({\n path: relPath,\n hash: fileHash,\n size: stat.size\n });\n } catch (error) {\n logWarn(`Could not process file ${relPath}: ${error}`);\n }\n }\n }\n }\n walkDir(dirPath, "");\n return files;\n}\nfunction createArchive(dirPath, tarPath) {\n try {\n execSync2(\n `tar -czf "${tarPath}" --exclude=\'.git\' --exclude=\'.vm0\' -C "${dirPath}" .`,\n { stdio: ["pipe", "pipe", "pipe"] }\n );\n return true;\n } catch (error) {\n logError(`Failed to create archive: ${error}`);\n return false;\n }\n}\nfunction createManifest(files, manifestPath) {\n try {\n const manifest = {\n version: 1,\n files,\n createdAt: (/* @__PURE__ */ new Date()).toISOString()\n };\n fs3.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));\n return true;\n } catch (error) {\n logError(`Failed to create manifest: ${error}`);\n return false;\n }\n}\nasync function createDirectUploadSnapshot(mountPath, storageName, storageType = "artifact", runId, message) {\n logInfo(\n `Creating direct upload snapshot for \'${storageName}\' (type: ${storageType})`\n );\n logInfo("Computing file hashes...");\n const hashStart = Date.now();\n const files = collectFileMetadata(mountPath);\n recordSandboxOp("artifact_hash_compute", Date.now() - hashStart, true);\n logInfo(`Found ${files.length} files`);\n if (files.length === 0) {\n logInfo("No files to upload, creating empty version");\n }\n logInfo("Calling prepare endpoint...");\n const prepareStart = Date.now();\n const preparePayload = {\n storageName,\n storageType,\n files\n };\n if (runId) {\n preparePayload.runId = runId;\n }\n const prepareResponse = await httpPostJson(\n STORAGE_PREPARE_URL,\n preparePayload\n );\n if (!prepareResponse) {\n logError("Failed to call prepare endpoint");\n recordSandboxOp("artifact_prepare_api", Date.now() - prepareStart, false);\n return null;\n }\n const versionId = prepareResponse.versionId;\n if (!versionId) {\n logError(`Invalid prepare response: ${JSON.stringify(prepareResponse)}`);\n recordSandboxOp("artifact_prepare_api", Date.now() - prepareStart, false);\n return null;\n }\n recordSandboxOp("artifact_prepare_api", Date.now() - prepareStart, true);\n if (prepareResponse.existing) {\n logInfo(`Version already exists (deduplicated): ${versionId.slice(0, 8)}`);\n logInfo("Updating HEAD pointer...");\n const commitPayload = {\n storageName,\n storageType,\n versionId,\n files\n };\n if (runId) {\n commitPayload.runId = runId;\n }\n const commitResponse = await httpPostJson(\n STORAGE_COMMIT_URL,\n commitPayload\n );\n if (!commitResponse || !commitResponse.success) {\n logError(`Failed to update HEAD: ${JSON.stringify(commitResponse)}`);\n return null;\n }\n return { versionId, deduplicated: true };\n }\n const uploads = prepareResponse.uploads;\n if (!uploads) {\n logError("No upload URLs in prepare response");\n return null;\n }\n const archiveInfo = uploads.archive;\n const manifestInfo = uploads.manifest;\n if (!archiveInfo || !manifestInfo) {\n logError("Missing archive or manifest upload info");\n return null;\n }\n const tempDir = fs3.mkdtempSync(`/tmp/direct-upload-${storageName}-`);\n try {\n logInfo("Creating archive...");\n const archiveStart = Date.now();\n const archivePath = path.join(tempDir, "archive.tar.gz");\n if (!createArchive(mountPath, archivePath)) {\n logError("Failed to create archive");\n recordSandboxOp(\n "artifact_archive_create",\n Date.now() - archiveStart,\n false\n );\n return null;\n }\n recordSandboxOp("artifact_archive_create", Date.now() - archiveStart, true);\n logInfo("Creating manifest...");\n const manifestPath = path.join(tempDir, "manifest.json");\n if (!createManifest(files, manifestPath)) {\n logError("Failed to create manifest");\n return null;\n }\n logInfo("Uploading archive to S3...");\n const s3UploadStart = Date.now();\n if (!await httpPutPresigned(\n archiveInfo.presignedUrl,\n archivePath,\n "application/gzip"\n )) {\n logError("Failed to upload archive to S3");\n recordSandboxOp("artifact_s3_upload", Date.now() - s3UploadStart, false);\n return null;\n }\n logInfo("Uploading manifest to S3...");\n if (!await httpPutPresigned(\n manifestInfo.presignedUrl,\n manifestPath,\n "application/json"\n )) {\n logError("Failed to upload manifest to S3");\n recordSandboxOp("artifact_s3_upload", Date.now() - s3UploadStart, false);\n return null;\n }\n recordSandboxOp("artifact_s3_upload", Date.now() - s3UploadStart, true);\n logInfo("Calling commit endpoint...");\n const commitStart = Date.now();\n const commitPayload = {\n storageName,\n storageType,\n versionId,\n files\n };\n if (runId) {\n commitPayload.runId = runId;\n }\n if (message) {\n commitPayload.message = message;\n }\n const commitResponse = await httpPostJson(\n STORAGE_COMMIT_URL,\n commitPayload\n );\n if (!commitResponse) {\n logError("Failed to call commit endpoint");\n recordSandboxOp("artifact_commit_api", Date.now() - commitStart, false);\n return null;\n }\n if (!commitResponse.success) {\n logError(`Commit failed: ${JSON.stringify(commitResponse)}`);\n recordSandboxOp("artifact_commit_api", Date.now() - commitStart, false);\n return null;\n }\n recordSandboxOp("artifact_commit_api", Date.now() - commitStart, true);\n logInfo(`Direct upload snapshot created: ${versionId.slice(0, 8)}`);\n return { versionId };\n } finally {\n try {\n fs3.rmSync(tempDir, { recursive: true, force: true });\n } catch {\n }\n }\n}\n\n// src/sandbox/scripts/src/lib/checkpoint.ts\nfunction findJsonlFiles(dir) {\n const files = [];\n function walk(currentDir) {\n try {\n const items = fs4.readdirSync(currentDir);\n for (const item of items) {\n const fullPath = path2.join(currentDir, item);\n const stat = fs4.statSync(fullPath);\n if (stat.isDirectory()) {\n walk(fullPath);\n } else if (item.endsWith(".jsonl")) {\n files.push(fullPath);\n }\n }\n } catch {\n }\n }\n walk(dir);\n return files;\n}\nfunction findCodexSessionFile(sessionsDir, sessionId) {\n const files = findJsonlFiles(sessionsDir);\n logInfo(`Searching for Codex session ${sessionId} in ${files.length} files`);\n for (const filepath of files) {\n const filename = path2.basename(filepath);\n if (filename.includes(sessionId) || filename.replace(/-/g, "").includes(sessionId.replace(/-/g, ""))) {\n logInfo(`Found Codex session file: ${filepath}`);\n return filepath;\n }\n }\n if (files.length > 0) {\n files.sort((a, b) => {\n const statA = fs4.statSync(a);\n const statB = fs4.statSync(b);\n return statB.mtimeMs - statA.mtimeMs;\n });\n const mostRecent = files[0] ?? null;\n if (mostRecent) {\n logInfo(\n `Session ID not found in filenames, using most recent: ${mostRecent}`\n );\n }\n return mostRecent;\n }\n return null;\n}\nasync function createCheckpoint() {\n const checkpointStart = Date.now();\n logInfo("Creating checkpoint...");\n const sessionIdStart = Date.now();\n if (!fs4.existsSync(SESSION_ID_FILE)) {\n logError("No session ID found, checkpoint creation failed");\n recordSandboxOp(\n "session_id_read",\n Date.now() - sessionIdStart,\n false,\n "Session ID file not found"\n );\n recordSandboxOp("checkpoint_total", Date.now() - checkpointStart, false);\n return false;\n }\n const cliAgentSessionId = fs4.readFileSync(SESSION_ID_FILE, "utf-8").trim();\n recordSandboxOp("session_id_read", Date.now() - sessionIdStart, true);\n const sessionHistoryStart = Date.now();\n if (!fs4.existsSync(SESSION_HISTORY_PATH_FILE)) {\n logError("No session history path found, checkpoint creation failed");\n recordSandboxOp(\n "session_history_read",\n Date.now() - sessionHistoryStart,\n false,\n "Session history path file not found"\n );\n recordSandboxOp("checkpoint_total", Date.now() - checkpointStart, false);\n return false;\n }\n const sessionHistoryPathRaw = fs4.readFileSync(SESSION_HISTORY_PATH_FILE, "utf-8").trim();\n let sessionHistoryPath;\n if (sessionHistoryPathRaw.startsWith("CODEX_SEARCH:")) {\n const parts = sessionHistoryPathRaw.split(":");\n if (parts.length !== 3) {\n logError(`Invalid Codex search marker format: ${sessionHistoryPathRaw}`);\n recordSandboxOp(\n "session_history_read",\n Date.now() - sessionHistoryStart,\n false,\n "Invalid Codex search marker"\n );\n recordSandboxOp("checkpoint_total", Date.now() - checkpointStart, false);\n return false;\n }\n const sessionsDir = parts[1] ?? "";\n const codexSessionId = parts[2] ?? "";\n logInfo(`Searching for Codex session in ${sessionsDir}`);\n const foundPath = findCodexSessionFile(sessionsDir, codexSessionId);\n if (!foundPath) {\n logError(\n `Could not find Codex session file for ${codexSessionId} in ${sessionsDir}`\n );\n recordSandboxOp(\n "session_history_read",\n Date.now() - sessionHistoryStart,\n false,\n "Codex session file not found"\n );\n recordSandboxOp("checkpoint_total", Date.now() - checkpointStart, false);\n return false;\n }\n sessionHistoryPath = foundPath;\n } else {\n sessionHistoryPath = sessionHistoryPathRaw;\n }\n if (!fs4.existsSync(sessionHistoryPath)) {\n logError(\n `Session history file not found at ${sessionHistoryPath}, checkpoint creation failed`\n );\n recordSandboxOp(\n "session_history_read",\n Date.now() - sessionHistoryStart,\n false,\n "Session history file not found"\n );\n recordSandboxOp("checkpoint_total", Date.now() - checkpointStart, false);\n return false;\n }\n let cliAgentSessionHistory;\n try {\n cliAgentSessionHistory = fs4.readFileSync(sessionHistoryPath, "utf-8");\n } catch (error) {\n logError(`Failed to read session history: ${error}`);\n recordSandboxOp(\n "session_history_read",\n Date.now() - sessionHistoryStart,\n false,\n String(error)\n );\n recordSandboxOp("checkpoint_total", Date.now() - checkpointStart, false);\n return false;\n }\n if (!cliAgentSessionHistory.trim()) {\n logError("Session history is empty, checkpoint creation failed");\n recordSandboxOp(\n "session_history_read",\n Date.now() - sessionHistoryStart,\n false,\n "Session history empty"\n );\n recordSandboxOp("checkpoint_total", Date.now() - checkpointStart, false);\n return false;\n }\n const lineCount = cliAgentSessionHistory.trim().split("\\n").length;\n logInfo(`Session history loaded (${lineCount} lines)`);\n recordSandboxOp(\n "session_history_read",\n Date.now() - sessionHistoryStart,\n true\n );\n let artifactSnapshot = null;\n if (ARTIFACT_DRIVER && ARTIFACT_VOLUME_NAME) {\n logInfo(`Processing artifact with driver: ${ARTIFACT_DRIVER}`);\n if (ARTIFACT_DRIVER !== "vas") {\n logError(\n `Unknown artifact driver: ${ARTIFACT_DRIVER} (only \'vas\' is supported)`\n );\n recordSandboxOp("checkpoint_total", Date.now() - checkpointStart, false);\n return false;\n }\n logInfo(\n `Creating VAS snapshot for artifact \'${ARTIFACT_VOLUME_NAME}\' at ${ARTIFACT_MOUNT_PATH}`\n );\n logInfo("Using direct S3 upload...");\n const snapshot = await createDirectUploadSnapshot(\n ARTIFACT_MOUNT_PATH,\n ARTIFACT_VOLUME_NAME,\n "artifact",\n RUN_ID,\n `Checkpoint from run ${RUN_ID}`\n );\n if (!snapshot) {\n logError("Failed to create VAS snapshot for artifact");\n recordSandboxOp("checkpoint_total", Date.now() - checkpointStart, false);\n return false;\n }\n const artifactVersion = snapshot.versionId;\n if (!artifactVersion) {\n logError("Failed to extract versionId from snapshot");\n recordSandboxOp("checkpoint_total", Date.now() - checkpointStart, false);\n return false;\n }\n artifactSnapshot = {\n artifactName: ARTIFACT_VOLUME_NAME,\n artifactVersion\n };\n logInfo(\n `VAS artifact snapshot created: ${ARTIFACT_VOLUME_NAME}@${artifactVersion}`\n );\n } else {\n logInfo(\n "No artifact configured, creating checkpoint without artifact snapshot"\n );\n }\n logInfo("Calling checkpoint API...");\n const checkpointPayload = {\n runId: RUN_ID,\n cliAgentType: CLI_AGENT_TYPE,\n cliAgentSessionId,\n cliAgentSessionHistory\n };\n if (artifactSnapshot) {\n checkpointPayload.artifactSnapshot = artifactSnapshot;\n }\n const apiCallStart = Date.now();\n const result = await httpPostJson(\n CHECKPOINT_URL,\n checkpointPayload\n );\n if (result && result.checkpointId) {\n const checkpointId = result.checkpointId;\n logInfo(`Checkpoint created successfully: ${checkpointId}`);\n recordSandboxOp("checkpoint_api_call", Date.now() - apiCallStart, true);\n recordSandboxOp("checkpoint_total", Date.now() - checkpointStart, true);\n return true;\n } else {\n logError(\n `Checkpoint API returned invalid response: ${JSON.stringify(result)}`\n );\n recordSandboxOp(\n "checkpoint_api_call",\n Date.now() - apiCallStart,\n false,\n "Invalid API response"\n );\n recordSandboxOp("checkpoint_total", Date.now() - checkpointStart, false);\n return false;\n }\n}\n\n// src/sandbox/scripts/src/lib/metrics.ts\nimport * as fs5 from "fs";\nimport { execSync as execSync3 } from "child_process";\nvar shutdownRequested = false;\nfunction getCpuPercent() {\n try {\n const content = fs5.readFileSync("/proc/stat", "utf-8");\n const line = content.split("\\n")[0];\n if (!line) {\n return 0;\n }\n const parts = line.split(/\\s+/);\n if (parts[0] !== "cpu") {\n return 0;\n }\n const values = parts.slice(1).map((x) => parseInt(x, 10));\n const idleVal = values[3];\n const iowaitVal = values[4];\n if (idleVal === void 0 || iowaitVal === void 0) {\n return 0;\n }\n const idle = idleVal + iowaitVal;\n const total = values.reduce((a, b) => a + b, 0);\n if (total === 0) {\n return 0;\n }\n const cpuPercent = 100 * (1 - idle / total);\n return Math.round(cpuPercent * 100) / 100;\n } catch (error) {\n logDebug(`Failed to get CPU percent: ${error}`);\n return 0;\n }\n}\nfunction getMemoryInfo() {\n try {\n const result = execSync3("free -b", {\n encoding: "utf-8",\n timeout: 5e3,\n stdio: ["pipe", "pipe", "pipe"]\n });\n const lines = result.trim().split("\\n");\n for (const line of lines) {\n if (line.startsWith("Mem:")) {\n const parts = line.split(/\\s+/);\n const totalStr = parts[1];\n const usedStr = parts[2];\n if (!totalStr || !usedStr) {\n return [0, 0];\n }\n const total = parseInt(totalStr, 10);\n const used = parseInt(usedStr, 10);\n return [used, total];\n }\n }\n return [0, 0];\n } catch (error) {\n logDebug(`Failed to get memory info: ${error}`);\n return [0, 0];\n }\n}\nfunction getDiskInfo() {\n try {\n const result = execSync3("df -B1 /", {\n encoding: "utf-8",\n timeout: 5e3,\n stdio: ["pipe", "pipe", "pipe"]\n });\n const lines = result.trim().split("\\n");\n if (lines.length < 2) {\n return [0, 0];\n }\n const dataLine = lines[1];\n if (!dataLine) {\n return [0, 0];\n }\n const parts = dataLine.split(/\\s+/);\n const totalStr = parts[1];\n const usedStr = parts[2];\n if (!totalStr || !usedStr) {\n return [0, 0];\n }\n const total = parseInt(totalStr, 10);\n const used = parseInt(usedStr, 10);\n return [used, total];\n } catch (error) {\n logDebug(`Failed to get disk info: ${error}`);\n return [0, 0];\n }\n}\nfunction collectMetrics() {\n const cpu = getCpuPercent();\n const [memUsed, memTotal] = getMemoryInfo();\n const [diskUsed, diskTotal] = getDiskInfo();\n return {\n ts: (/* @__PURE__ */ new Date()).toISOString(),\n cpu,\n mem_used: memUsed,\n mem_total: memTotal,\n disk_used: diskUsed,\n disk_total: diskTotal\n };\n}\nfunction metricsCollectorLoop() {\n logInfo(`Metrics collector started, writing to ${METRICS_LOG_FILE}`);\n const writeMetrics = () => {\n if (shutdownRequested) {\n logInfo("Metrics collector stopped");\n return;\n }\n try {\n const metrics = collectMetrics();\n fs5.appendFileSync(METRICS_LOG_FILE, JSON.stringify(metrics) + "\\n");\n logDebug(\n `Metrics collected: cpu=${metrics.cpu}%, mem=${metrics.mem_used}/${metrics.mem_total}`\n );\n } catch (error) {\n logError(`Failed to collect/write metrics: ${error}`);\n }\n setTimeout(writeMetrics, METRICS_INTERVAL * 1e3);\n };\n writeMetrics();\n}\nfunction startMetricsCollector() {\n shutdownRequested = false;\n setTimeout(metricsCollectorLoop, 0);\n}\nfunction stopMetricsCollector() {\n shutdownRequested = true;\n}\n\n// src/sandbox/scripts/src/lib/upload-telemetry.ts\nimport * as fs6 from "fs";\nvar shutdownRequested2 = false;\nfunction readFileFromPosition(filePath, posFile) {\n let lastPos = 0;\n if (fs6.existsSync(posFile)) {\n try {\n const content = fs6.readFileSync(posFile, "utf-8").trim();\n lastPos = parseInt(content, 10) || 0;\n } catch {\n lastPos = 0;\n }\n }\n let newContent = "";\n let newPos = lastPos;\n if (fs6.existsSync(filePath)) {\n try {\n const fd = fs6.openSync(filePath, "r");\n const stats = fs6.fstatSync(fd);\n const bufferSize = stats.size - lastPos;\n if (bufferSize > 0) {\n const buffer = Buffer.alloc(bufferSize);\n fs6.readSync(fd, buffer, 0, bufferSize, lastPos);\n newContent = buffer.toString("utf-8");\n newPos = stats.size;\n }\n fs6.closeSync(fd);\n } catch (error) {\n logDebug(`Failed to read ${filePath}: ${error}`);\n }\n }\n return [newContent, newPos];\n}\nfunction savePosition(posFile, position) {\n try {\n fs6.writeFileSync(posFile, String(position));\n } catch (error) {\n logDebug(`Failed to save position to ${posFile}: ${error}`);\n }\n}\nfunction readJsonlFromPosition(filePath, posFile) {\n const [content, newPos] = readFileFromPosition(filePath, posFile);\n const entries = [];\n if (content) {\n for (const line of content.trim().split("\\n")) {\n if (line) {\n try {\n entries.push(JSON.parse(line));\n } catch {\n }\n }\n }\n }\n return [entries, newPos];\n}\nfunction readMetricsFromPosition(posFile) {\n return readJsonlFromPosition(METRICS_LOG_FILE, posFile);\n}\nfunction readNetworkLogsFromPosition(posFile) {\n return readJsonlFromPosition(NETWORK_LOG_FILE, posFile);\n}\nfunction readSandboxOpsFromPosition(posFile) {\n return readJsonlFromPosition(SANDBOX_OPS_LOG_FILE, posFile);\n}\nasync function uploadTelemetry() {\n const [systemLog, logPos] = readFileFromPosition(\n SYSTEM_LOG_FILE,\n TELEMETRY_LOG_POS_FILE\n );\n const [metrics, metricsPos] = readMetricsFromPosition(\n TELEMETRY_METRICS_POS_FILE\n );\n const [networkLogs, networkPos] = readNetworkLogsFromPosition(\n TELEMETRY_NETWORK_POS_FILE\n );\n const [sandboxOps, sandboxOpsPos] = readSandboxOpsFromPosition(\n TELEMETRY_SANDBOX_OPS_POS_FILE\n );\n if (!systemLog && metrics.length === 0 && networkLogs.length === 0 && sandboxOps.length === 0) {\n logDebug("No new telemetry data to upload");\n return true;\n }\n const maskedSystemLog = systemLog ? maskData(systemLog) : "";\n const maskedNetworkLogs = networkLogs.length > 0 ? maskData(networkLogs) : [];\n const payload = {\n runId: RUN_ID,\n systemLog: maskedSystemLog,\n metrics,\n // Metrics don\'t contain secrets (just numbers)\n networkLogs: maskedNetworkLogs,\n sandboxOperations: sandboxOps\n // Sandbox ops don\'t contain secrets (just timing data)\n };\n logDebug(\n `Uploading telemetry: ${systemLog.length} bytes log, ${metrics.length} metrics, ${networkLogs.length} network logs, ${sandboxOps.length} sandbox ops`\n );\n const result = await httpPostJson(TELEMETRY_URL, payload, 1);\n if (result) {\n savePosition(TELEMETRY_LOG_POS_FILE, logPos);\n savePosition(TELEMETRY_METRICS_POS_FILE, metricsPos);\n savePosition(TELEMETRY_NETWORK_POS_FILE, networkPos);\n savePosition(TELEMETRY_SANDBOX_OPS_POS_FILE, sandboxOpsPos);\n logDebug(\n `Telemetry uploaded successfully: ${result.id ?? "unknown"}`\n );\n return true;\n } else {\n logWarn("Failed to upload telemetry (will retry next interval)");\n return false;\n }\n}\nasync function telemetryUploadLoop() {\n logInfo(`Telemetry upload started (interval: ${TELEMETRY_INTERVAL}s)`);\n const runUpload = async () => {\n if (shutdownRequested2) {\n logInfo("Telemetry upload stopped");\n return;\n }\n try {\n await uploadTelemetry();\n } catch (error) {\n logError(`Telemetry upload error: ${error}`);\n }\n setTimeout(() => void runUpload(), TELEMETRY_INTERVAL * 1e3);\n };\n await runUpload();\n}\nfunction startTelemetryUpload() {\n shutdownRequested2 = false;\n setTimeout(() => void telemetryUploadLoop(), 0);\n}\nfunction stopTelemetryUpload() {\n shutdownRequested2 = true;\n}\nasync function finalTelemetryUpload() {\n logInfo("Performing final telemetry upload...");\n return uploadTelemetry();\n}\n\n// src/sandbox/scripts/src/run-agent.ts\nvar shutdownRequested3 = false;\nfunction heartbeatLoop() {\n const sendHeartbeat = async () => {\n if (shutdownRequested3) {\n return;\n }\n try {\n if (await httpPostJson(HEARTBEAT_URL, { runId: RUN_ID })) {\n logInfo("Heartbeat sent");\n } else {\n logWarn("Heartbeat failed");\n }\n } catch (error) {\n logWarn(`Heartbeat error: ${error}`);\n }\n setTimeout(() => {\n sendHeartbeat().catch(() => {\n });\n }, HEARTBEAT_INTERVAL * 1e3);\n };\n sendHeartbeat().catch(() => {\n });\n}\nasync function cleanup(exitCode, errorMessage) {\n logInfo("\\u25B7 Cleanup");\n const telemetryStart = Date.now();\n let telemetrySuccess = true;\n try {\n await finalTelemetryUpload();\n } catch (error) {\n telemetrySuccess = false;\n logError(`Final telemetry upload failed: ${error}`);\n }\n recordSandboxOp(\n "final_telemetry_upload",\n Date.now() - telemetryStart,\n telemetrySuccess\n );\n logInfo(`Calling complete API with exitCode=${exitCode}`);\n const completePayload = {\n runId: RUN_ID,\n exitCode\n };\n if (errorMessage) {\n completePayload.error = errorMessage;\n }\n const completeStart = Date.now();\n let completeSuccess = false;\n try {\n if (await httpPostJson(COMPLETE_URL, completePayload)) {\n logInfo("Complete API called successfully");\n completeSuccess = true;\n } else {\n logError("Failed to call complete API (sandbox may not be cleaned up)");\n }\n } catch (error) {\n logError(`Complete API call failed: ${error}`);\n }\n recordSandboxOp(\n "complete_api_call",\n Date.now() - completeStart,\n completeSuccess\n );\n shutdownRequested3 = true;\n stopMetricsCollector();\n stopTelemetryUpload();\n logInfo("Background processes stopped");\n if (exitCode === 0) {\n logInfo("\\u2713 Sandbox finished successfully");\n } else {\n logInfo(`\\u2717 Sandbox failed (exit code ${exitCode})`);\n }\n}\nasync function run() {\n validateConfig();\n logInfo(`\\u25B6 VM0 Sandbox ${RUN_ID}`);\n logInfo("\\u25B7 Initialization");\n const initStartTime = Date.now();\n logInfo(`Working directory: ${WORKING_DIR}`);\n const heartbeatStart = Date.now();\n heartbeatLoop();\n logInfo("Heartbeat started");\n recordSandboxOp("heartbeat_start", Date.now() - heartbeatStart, true);\n const metricsStart = Date.now();\n startMetricsCollector();\n logInfo("Metrics collector started");\n recordSandboxOp("metrics_collector_start", Date.now() - metricsStart, true);\n const telemetryStart = Date.now();\n startTelemetryUpload();\n logInfo("Telemetry upload started");\n recordSandboxOp("telemetry_upload_start", Date.now() - telemetryStart, true);\n const workingDirStart = Date.now();\n try {\n fs7.mkdirSync(WORKING_DIR, { recursive: true });\n process.chdir(WORKING_DIR);\n } catch (error) {\n recordSandboxOp(\n "working_dir_setup",\n Date.now() - workingDirStart,\n false,\n String(error)\n );\n throw new Error(\n `Failed to create/change to working directory: ${WORKING_DIR} - ${error}`\n );\n }\n recordSandboxOp("working_dir_setup", Date.now() - workingDirStart, true);\n if (CLI_AGENT_TYPE === "codex") {\n const homeDir = process.env.HOME ?? "/home/user";\n const codexHome = `${homeDir}/.codex`;\n fs7.mkdirSync(codexHome, { recursive: true });\n process.env.CODEX_HOME = codexHome;\n logInfo(`Codex home directory: ${codexHome}`);\n const codexLoginStart = Date.now();\n let codexLoginSuccess = false;\n const apiKey = process.env.OPENAI_API_KEY ?? "";\n if (apiKey) {\n try {\n execSync4("codex login --with-api-key", {\n input: apiKey,\n encoding: "utf-8",\n stdio: ["pipe", "pipe", "pipe"]\n });\n logInfo("Codex authenticated with API key");\n codexLoginSuccess = true;\n } catch (error) {\n logError(`Codex login failed: ${error}`);\n }\n } else {\n logError("OPENAI_API_KEY not set");\n }\n recordSandboxOp(\n "codex_login",\n Date.now() - codexLoginStart,\n codexLoginSuccess\n );\n }\n const initDurationMs = Date.now() - initStartTime;\n recordSandboxOp("init_total", initDurationMs, true);\n logInfo(`\\u2713 Initialization complete (${Math.floor(initDurationMs / 1e3)}s)`);\n logInfo("\\u25B7 Execution");\n const execStartTime = Date.now();\n logInfo(`Starting ${CLI_AGENT_TYPE} execution...`);\n logInfo(`Prompt: ${PROMPT}`);\n const useMock = process.env.USE_MOCK_CLAUDE === "true";\n let cmd;\n if (CLI_AGENT_TYPE === "codex") {\n if (useMock) {\n throw new Error("Mock mode not supported for Codex");\n }\n const codexArgs = [\n "exec",\n "--json",\n "--dangerously-bypass-approvals-and-sandbox",\n "--skip-git-repo-check",\n "-C",\n WORKING_DIR\n ];\n if (OPENAI_MODEL) {\n codexArgs.push("-m", OPENAI_MODEL);\n }\n if (RESUME_SESSION_ID) {\n logInfo(`Resuming session: ${RESUME_SESSION_ID}`);\n codexArgs.push("resume", RESUME_SESSION_ID, PROMPT);\n } else {\n logInfo("Starting new session");\n codexArgs.push(PROMPT);\n }\n cmd = ["codex", ...codexArgs];\n } else {\n const claudeArgs = [\n "--print",\n "--verbose",\n "--output-format",\n "stream-json",\n "--dangerously-skip-permissions"\n ];\n if (RESUME_SESSION_ID) {\n logInfo(`Resuming session: ${RESUME_SESSION_ID}`);\n claudeArgs.push("--resume", RESUME_SESSION_ID);\n } else {\n logInfo("Starting new session");\n }\n const claudeBin = useMock ? "/usr/local/bin/vm0-agent/mock-claude.mjs" : "claude";\n if (useMock) {\n logInfo("Using mock-claude for testing");\n }\n cmd = [claudeBin, ...claudeArgs, PROMPT];\n }\n let agentExitCode = 0;\n const stderrLines = [];\n let logFile = null;\n try {\n logFile = fs7.createWriteStream(AGENT_LOG_FILE);\n const cmdExe = cmd[0];\n if (!cmdExe) {\n throw new Error("Empty command");\n }\n const proc = spawn(cmdExe, cmd.slice(1), {\n stdio: ["pipe", "pipe", "pipe"]\n });\n const exitPromise = new Promise((resolve) => {\n let resolved = false;\n proc.on("error", (err) => {\n if (!resolved) {\n resolved = true;\n logError(`Failed to spawn ${CLI_AGENT_TYPE}: ${err.message}`);\n stderrLines.push(`Spawn error: ${err.message}`);\n resolve(1);\n }\n });\n proc.on("close", (code) => {\n if (!resolved) {\n resolved = true;\n resolve(code ?? 1);\n }\n });\n });\n if (proc.stderr) {\n const stderrRl = readline.createInterface({ input: proc.stderr });\n stderrRl.on("line", (line) => {\n stderrLines.push(line);\n if (logFile && !logFile.destroyed) {\n logFile.write(`[STDERR] ${line}\n`);\n }\n });\n }\n if (proc.stdout) {\n const stdoutRl = readline.createInterface({ input: proc.stdout });\n let eventSequence = 0;\n for await (const line of stdoutRl) {\n if (logFile && !logFile.destroyed) {\n logFile.write(line + "\\n");\n }\n const stripped = line.trim();\n if (!stripped) {\n continue;\n }\n try {\n const event = JSON.parse(stripped);\n eventSequence++;\n await sendEvent(event, eventSequence);\n if (event.type === "result") {\n const resultContent = event.result;\n if (resultContent) {\n console.log(resultContent);\n }\n }\n } catch {\n logDebug(`Non-JSON line from agent: ${stripped.slice(0, 100)}`);\n }\n }\n }\n agentExitCode = await exitPromise;\n } catch (error) {\n logError(`Failed to execute ${CLI_AGENT_TYPE}: ${error}`);\n agentExitCode = 1;\n } finally {\n if (logFile && !logFile.destroyed) {\n logFile.end();\n }\n }\n console.log();\n let finalExitCode = agentExitCode;\n let errorMessage = "";\n if (fs7.existsSync(EVENT_ERROR_FLAG)) {\n logError("Some events failed to send, marking run as failed");\n finalExitCode = 1;\n errorMessage = "Some events failed to send";\n }\n const execDurationMs = Date.now() - execStartTime;\n recordSandboxOp("cli_execution", execDurationMs, agentExitCode === 0);\n if (agentExitCode === 0 && finalExitCode === 0) {\n logInfo(`\\u2713 Execution complete (${Math.floor(execDurationMs / 1e3)}s)`);\n } else {\n logInfo(`\\u2717 Execution failed (${Math.floor(execDurationMs / 1e3)}s)`);\n }\n if (agentExitCode === 0 && finalExitCode === 0) {\n logInfo(`${CLI_AGENT_TYPE} completed successfully`);\n logInfo("\\u25B7 Checkpoint");\n const checkpointStartTime = Date.now();\n const checkpointSuccess = await createCheckpoint();\n const checkpointDuration = Math.floor(\n (Date.now() - checkpointStartTime) / 1e3\n );\n if (checkpointSuccess) {\n logInfo(`\\u2713 Checkpoint complete (${checkpointDuration}s)`);\n } else {\n logInfo(`\\u2717 Checkpoint failed (${checkpointDuration}s)`);\n }\n if (!checkpointSuccess) {\n logError("Checkpoint creation failed, marking run as failed");\n finalExitCode = 1;\n errorMessage = "Checkpoint creation failed";\n }\n } else {\n if (agentExitCode !== 0) {\n logInfo(`${CLI_AGENT_TYPE} failed with exit code ${agentExitCode}`);\n if (stderrLines.length > 0) {\n errorMessage = stderrLines.map((line) => line.trim()).join(" ");\n logInfo(`Captured stderr: ${errorMessage}`);\n } else {\n errorMessage = `Agent exited with code ${agentExitCode}`;\n }\n }\n }\n return [finalExitCode, errorMessage];\n}\nasync function main() {\n let exitCode = 1;\n let errorMessage = "Unexpected termination";\n try {\n [exitCode, errorMessage] = await run();\n } catch (error) {\n if (error instanceof Error) {\n exitCode = 1;\n errorMessage = error.message;\n logError(`Error: ${errorMessage}`);\n } else {\n exitCode = 1;\n errorMessage = `Unexpected error: ${error}`;\n logError(errorMessage);\n }\n } finally {\n await cleanup(exitCode, errorMessage);\n }\n return exitCode;\n}\nmain().then((code) => process.exit(code)).catch((error) => {\n console.error("Fatal error:", error);\n process.exit(1);\n});\n';
|
|
7123
|
+
var DOWNLOAD_SCRIPT = '#!/usr/bin/env node\n\n// src/sandbox/scripts/src/download.ts\nimport * as fs2 from "fs";\nimport * as path from "path";\nimport * as os from "os";\nimport { execSync as execSync2 } from "child_process";\n\n// src/sandbox/scripts/src/lib/common.ts\nimport * as fs from "fs";\nvar RUN_ID = process.env.VM0_RUN_ID ?? "";\nvar API_URL = process.env.VM0_API_URL ?? "";\nvar API_TOKEN = process.env.VM0_API_TOKEN ?? "";\nvar PROMPT = process.env.VM0_PROMPT ?? "";\nvar VERCEL_BYPASS = process.env.VERCEL_PROTECTION_BYPASS ?? "";\nvar RESUME_SESSION_ID = process.env.VM0_RESUME_SESSION_ID ?? "";\nvar CLI_AGENT_TYPE = process.env.CLI_AGENT_TYPE ?? "claude-code";\nvar OPENAI_MODEL = process.env.OPENAI_MODEL ?? "";\nvar WORKING_DIR = process.env.VM0_WORKING_DIR ?? "";\nvar ARTIFACT_DRIVER = process.env.VM0_ARTIFACT_DRIVER ?? "";\nvar ARTIFACT_MOUNT_PATH = process.env.VM0_ARTIFACT_MOUNT_PATH ?? "";\nvar ARTIFACT_VOLUME_NAME = process.env.VM0_ARTIFACT_VOLUME_NAME ?? "";\nvar ARTIFACT_VERSION_ID = process.env.VM0_ARTIFACT_VERSION_ID ?? "";\nvar WEBHOOK_URL = `${API_URL}/api/webhooks/agent/events`;\nvar CHECKPOINT_URL = `${API_URL}/api/webhooks/agent/checkpoints`;\nvar COMPLETE_URL = `${API_URL}/api/webhooks/agent/complete`;\nvar HEARTBEAT_URL = `${API_URL}/api/webhooks/agent/heartbeat`;\nvar TELEMETRY_URL = `${API_URL}/api/webhooks/agent/telemetry`;\nvar PROXY_URL = `${API_URL}/api/webhooks/agent/proxy`;\nvar STORAGE_PREPARE_URL = `${API_URL}/api/webhooks/agent/storages/prepare`;\nvar STORAGE_COMMIT_URL = `${API_URL}/api/webhooks/agent/storages/commit`;\nvar HTTP_MAX_TIME_UPLOAD = 60;\nvar HTTP_MAX_RETRIES = 3;\nvar SESSION_ID_FILE = `/tmp/vm0-session-${RUN_ID}.txt`;\nvar SESSION_HISTORY_PATH_FILE = `/tmp/vm0-session-history-${RUN_ID}.txt`;\nvar EVENT_ERROR_FLAG = `/tmp/vm0-event-error-${RUN_ID}`;\nvar SYSTEM_LOG_FILE = `/tmp/vm0-main-${RUN_ID}.log`;\nvar AGENT_LOG_FILE = `/tmp/vm0-agent-${RUN_ID}.log`;\nvar METRICS_LOG_FILE = `/tmp/vm0-metrics-${RUN_ID}.jsonl`;\nvar NETWORK_LOG_FILE = `/tmp/vm0-network-${RUN_ID}.jsonl`;\nvar TELEMETRY_LOG_POS_FILE = `/tmp/vm0-telemetry-log-pos-${RUN_ID}.txt`;\nvar TELEMETRY_METRICS_POS_FILE = `/tmp/vm0-telemetry-metrics-pos-${RUN_ID}.txt`;\nvar TELEMETRY_NETWORK_POS_FILE = `/tmp/vm0-telemetry-network-pos-${RUN_ID}.txt`;\nvar TELEMETRY_SANDBOX_OPS_POS_FILE = `/tmp/vm0-telemetry-sandbox-ops-pos-${RUN_ID}.txt`;\nvar SANDBOX_OPS_LOG_FILE = `/tmp/vm0-sandbox-ops-${RUN_ID}.jsonl`;\nfunction recordSandboxOp(actionType, durationMs, success, error) {\n const entry = {\n ts: (/* @__PURE__ */ new Date()).toISOString(),\n action_type: actionType,\n duration_ms: durationMs,\n success\n };\n if (error) {\n entry.error = error;\n }\n fs.appendFileSync(SANDBOX_OPS_LOG_FILE, JSON.stringify(entry) + "\\n");\n}\n\n// src/sandbox/scripts/src/lib/log.ts\nvar SCRIPT_NAME = process.env.LOG_SCRIPT_NAME ?? "run-agent";\nvar DEBUG_MODE = process.env.VM0_DEBUG === "1";\nfunction timestamp() {\n return (/* @__PURE__ */ new Date()).toISOString().replace(/\\.\\d{3}Z$/, "Z");\n}\nfunction logInfo(msg) {\n console.error(`[${timestamp()}] [INFO] [sandbox:${SCRIPT_NAME}] ${msg}`);\n}\nfunction logWarn(msg) {\n console.error(`[${timestamp()}] [WARN] [sandbox:${SCRIPT_NAME}] ${msg}`);\n}\nfunction logError(msg) {\n console.error(`[${timestamp()}] [ERROR] [sandbox:${SCRIPT_NAME}] ${msg}`);\n}\nfunction logDebug(msg) {\n if (DEBUG_MODE) {\n console.error(`[${timestamp()}] [DEBUG] [sandbox:${SCRIPT_NAME}] ${msg}`);\n }\n}\n\n// src/sandbox/scripts/src/lib/http-client.ts\nimport { execSync } from "child_process";\nfunction sleep(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\nasync function httpDownload(url, destPath, maxRetries = HTTP_MAX_RETRIES) {\n for (let attempt = 1; attempt <= maxRetries; attempt++) {\n logDebug(`HTTP download attempt ${attempt}/${maxRetries} from ${url}`);\n try {\n const curlCmd = ["curl", "-fsSL", "-o", destPath, `"${url}"`].join(" ");\n execSync(curlCmd, {\n timeout: HTTP_MAX_TIME_UPLOAD * 1e3,\n stdio: ["pipe", "pipe", "pipe"]\n });\n return true;\n } catch (error) {\n const errorMsg = error instanceof Error ? error.message : String(error);\n if (errorMsg.includes("ETIMEDOUT") || errorMsg.includes("timeout")) {\n logWarn(\n `HTTP download failed (attempt ${attempt}/${maxRetries}): Timeout`\n );\n } else {\n logWarn(\n `HTTP download failed (attempt ${attempt}/${maxRetries}): ${errorMsg}`\n );\n }\n if (attempt < maxRetries) {\n await sleep(1e3);\n }\n }\n }\n logError(`HTTP download failed after ${maxRetries} attempts from ${url}`);\n return false;\n}\n\n// src/sandbox/scripts/src/download.ts\nasync function downloadStorage(mountPath, archiveUrl) {\n logInfo(`Downloading storage to ${mountPath}`);\n const tempTar = path.join(\n os.tmpdir(),\n `storage-${Date.now()}-${Math.random().toString(36).slice(2)}.tar.gz`\n );\n try {\n if (!await httpDownload(archiveUrl, tempTar)) {\n logError(`Failed to download archive for ${mountPath}`);\n return false;\n }\n fs2.mkdirSync(mountPath, { recursive: true });\n try {\n execSync2(`tar -xzf "${tempTar}" -C "${mountPath}"`, {\n stdio: ["pipe", "pipe", "pipe"]\n });\n } catch {\n logInfo(`Archive appears empty for ${mountPath}`);\n }\n logInfo(`Successfully extracted to ${mountPath}`);\n return true;\n } finally {\n try {\n fs2.unlinkSync(tempTar);\n } catch {\n }\n }\n}\nasync function main() {\n const args = process.argv.slice(2);\n if (args.length < 1) {\n logError("Usage: node download.mjs <manifest_path>");\n process.exit(1);\n }\n const manifestPath = args[0] ?? "";\n if (!manifestPath || !fs2.existsSync(manifestPath)) {\n logError(`Manifest file not found: ${manifestPath}`);\n process.exit(1);\n }\n logInfo(`Starting storage download from manifest: ${manifestPath}`);\n let manifest;\n try {\n const content = fs2.readFileSync(manifestPath, "utf-8");\n manifest = JSON.parse(content);\n } catch (error) {\n logError(`Failed to load manifest: ${error}`);\n process.exit(1);\n }\n const storages = manifest.storages ?? [];\n const artifact = manifest.artifact;\n const storageCount = storages.length;\n const hasArtifact = artifact !== void 0;\n logInfo(`Found ${storageCount} storages, artifact: ${hasArtifact}`);\n const downloadTotalStart = Date.now();\n let downloadSuccess = true;\n for (const storage of storages) {\n const mountPath = storage.mountPath;\n const archiveUrl = storage.archiveUrl;\n if (archiveUrl && archiveUrl !== "null") {\n const storageStart = Date.now();\n const success = await downloadStorage(mountPath, archiveUrl);\n recordSandboxOp("storage_download", Date.now() - storageStart, success);\n if (!success) {\n downloadSuccess = false;\n }\n }\n }\n if (artifact) {\n const artifactMount = artifact.mountPath;\n const artifactUrl = artifact.archiveUrl;\n if (artifactUrl && artifactUrl !== "null") {\n const artifactStart = Date.now();\n const success = await downloadStorage(artifactMount, artifactUrl);\n recordSandboxOp("artifact_download", Date.now() - artifactStart, success);\n if (!success) {\n downloadSuccess = false;\n }\n }\n }\n recordSandboxOp(\n "download_total",\n Date.now() - downloadTotalStart,\n downloadSuccess\n );\n logInfo("All storages downloaded successfully");\n}\nmain().catch((error) => {\n logError(`Fatal error: ${error}`);\n process.exit(1);\n});\n';
|
|
7124
|
+
var MOCK_CLAUDE_SCRIPT = '#!/usr/bin/env node\n\n// src/sandbox/scripts/src/mock-claude.ts\nimport * as fs from "fs";\nimport * as path from "path";\nimport { execSync } from "child_process";\nfunction parseArgs(args) {\n const result = {\n outputFormat: "text",\n print: false,\n verbose: false,\n dangerouslySkipPermissions: false,\n resume: null,\n prompt: ""\n };\n const remaining = [];\n let i = 0;\n while (i < args.length) {\n const arg = args[i];\n if (arg === "--output-format" && i + 1 < args.length) {\n result.outputFormat = args[i + 1] ?? "text";\n i += 2;\n } else if (arg === "--print") {\n result.print = true;\n i++;\n } else if (arg === "--verbose") {\n result.verbose = true;\n i++;\n } else if (arg === "--dangerously-skip-permissions") {\n result.dangerouslySkipPermissions = true;\n i++;\n } else if (arg === "--resume" && i + 1 < args.length) {\n result.resume = args[i + 1] ?? null;\n i += 2;\n } else if (arg) {\n remaining.push(arg);\n i++;\n } else {\n i++;\n }\n }\n if (remaining.length > 0) {\n result.prompt = remaining[0] ?? "";\n }\n return result;\n}\nfunction createSessionHistory(sessionId, cwd) {\n const projectName = cwd.replace(/^\\//, "").replace(/\\//g, "-");\n const homeDir = process.env.HOME ?? "/home/user";\n const sessionDir = `${homeDir}/.claude/projects/-${projectName}`;\n fs.mkdirSync(sessionDir, { recursive: true });\n return path.join(sessionDir, `${sessionId}.jsonl`);\n}\nfunction main() {\n const sessionId = `mock-${Date.now() * 1e3 + Math.floor(Math.random() * 1e3)}`;\n const args = parseArgs(process.argv.slice(2));\n const prompt = args.prompt;\n const outputFormat = args.outputFormat;\n if (prompt.startsWith("@fail:")) {\n const errorMsg = prompt.slice(6);\n console.error(errorMsg);\n process.exit(1);\n }\n const cwd = process.cwd();\n if (outputFormat === "stream-json") {\n const sessionHistoryFile = createSessionHistory(sessionId, cwd);\n const events = [];\n const initEvent = {\n type: "system",\n subtype: "init",\n cwd,\n session_id: sessionId,\n tools: ["Bash"],\n model: "mock-claude"\n };\n console.log(JSON.stringify(initEvent));\n events.push(initEvent);\n const textEvent = {\n type: "assistant",\n message: {\n role: "assistant",\n content: [{ type: "text", text: "Executing command..." }]\n },\n session_id: sessionId\n };\n console.log(JSON.stringify(textEvent));\n events.push(textEvent);\n const toolUseEvent = {\n type: "assistant",\n message: {\n role: "assistant",\n content: [\n {\n type: "tool_use",\n id: "toolu_mock_001",\n name: "Bash",\n input: { command: prompt }\n }\n ]\n },\n session_id: sessionId\n };\n console.log(JSON.stringify(toolUseEvent));\n events.push(toolUseEvent);\n let output;\n let exitCode;\n try {\n output = execSync(`bash -c ${JSON.stringify(prompt)}`, {\n encoding: "utf-8",\n stdio: ["pipe", "pipe", "pipe"]\n });\n exitCode = 0;\n } catch (error) {\n const execError = error;\n output = (execError.stdout ?? "") + (execError.stderr ?? "");\n exitCode = execError.status ?? 1;\n }\n const isError = exitCode !== 0;\n const toolResultEvent = {\n type: "user",\n message: {\n role: "user",\n content: [\n {\n type: "tool_result",\n tool_use_id: "toolu_mock_001",\n content: output,\n is_error: isError\n }\n ]\n },\n session_id: sessionId\n };\n console.log(JSON.stringify(toolResultEvent));\n events.push(toolResultEvent);\n const resultEvent = {\n type: "result",\n subtype: exitCode === 0 ? "success" : "error",\n is_error: exitCode !== 0,\n duration_ms: 100,\n num_turns: 1,\n result: output,\n session_id: sessionId,\n total_cost_usd: 0,\n usage: { input_tokens: 0, output_tokens: 0 }\n };\n console.log(JSON.stringify(resultEvent));\n events.push(resultEvent);\n const historyContent = events.map((e) => JSON.stringify(e)).join("\\n") + "\\n";\n fs.writeFileSync(sessionHistoryFile, historyContent);\n process.exit(exitCode);\n } else {\n try {\n execSync(`bash -c ${JSON.stringify(prompt)}`, {\n stdio: "inherit"\n });\n process.exit(0);\n } catch (error) {\n const execError = error;\n process.exit(execError.status ?? 1);\n }\n }\n}\nvar isMainModule = process.argv[1]?.endsWith("mock-claude.mjs") || process.argv[1]?.endsWith("mock-claude.ts");\nif (isMainModule) {\n main();\n}\nexport {\n createSessionHistory,\n parseArgs\n};\n';
|
|
7125
|
+
var ENV_LOADER_SCRIPT = '#!/usr/bin/env node\n\n// src/sandbox/scripts/src/env-loader.ts\nimport * as fs from "fs";\nimport { spawn } from "child_process";\nvar ENV_JSON_PATH = "/tmp/vm0-env.json";\nconsole.log("[env-loader] Starting...");\nif (fs.existsSync(ENV_JSON_PATH)) {\n console.log(`[env-loader] Loading environment from ${ENV_JSON_PATH}`);\n try {\n const content = fs.readFileSync(ENV_JSON_PATH, "utf-8");\n const envData = JSON.parse(content);\n for (const [key, value] of Object.entries(envData)) {\n process.env[key] = value;\n }\n console.log(\n `[env-loader] Loaded ${Object.keys(envData).length} environment variables`\n );\n } catch (error) {\n console.error(`[env-loader] ERROR loading JSON: ${error}`);\n process.exit(1);\n }\n} else {\n console.error(\n `[env-loader] ERROR: Environment file not found: ${ENV_JSON_PATH}`\n );\n process.exit(1);\n}\nvar criticalVars = [\n "VM0_RUN_ID",\n "VM0_API_URL",\n "VM0_WORKING_DIR",\n "VM0_PROMPT"\n];\nfor (const varName of criticalVars) {\n const val = process.env[varName] ?? "";\n if (val) {\n const display = val.length > 50 ? val.substring(0, 50) + "..." : val;\n console.log(`[env-loader] ${varName}=${display}`);\n } else {\n console.log(`[env-loader] WARNING: ${varName} is empty`);\n }\n}\nvar runAgentPath = "/usr/local/bin/vm0-agent/run-agent.mjs";\nconsole.log(`[env-loader] Executing ${runAgentPath}`);\nvar child = spawn("node", [runAgentPath], {\n stdio: "inherit",\n env: process.env\n});\nchild.on("close", (code) => {\n process.exit(code ?? 1);\n});\n';
|
|
7102
7126
|
|
|
7103
|
-
|
|
7104
|
-
|
|
7105
|
-
|
|
7127
|
+
// ../../packages/core/src/sandbox/scripts/index.ts
|
|
7128
|
+
var SCRIPT_PATHS = {
|
|
7129
|
+
/** Base directory for agent scripts */
|
|
7130
|
+
baseDir: "/usr/local/bin/vm0-agent",
|
|
7131
|
+
/** Main agent orchestrator - handles CLI execution, events, checkpoints */
|
|
7132
|
+
runAgent: "/usr/local/bin/vm0-agent/run-agent.mjs",
|
|
7133
|
+
/** Storage download - downloads volumes/artifacts from S3 via presigned URLs */
|
|
7134
|
+
download: "/usr/local/bin/vm0-agent/download.mjs",
|
|
7135
|
+
/** Mock Claude CLI for testing - executes prompt as bash, outputs Claude-compatible JSONL */
|
|
7136
|
+
mockClaude: "/usr/local/bin/vm0-agent/mock-claude.mjs",
|
|
7137
|
+
/** Environment loader for runner - loads env from JSON file before running agent */
|
|
7138
|
+
envLoader: "/usr/local/bin/vm0-agent/env-loader.mjs"
|
|
7139
|
+
};
|
|
7140
|
+
|
|
7141
|
+
// src/lib/scripts/index.ts
|
|
7142
|
+
var ENV_LOADER_PATH = "/usr/local/bin/vm0-agent/env-loader.mjs";
|
|
7143
|
+
|
|
7144
|
+
// src/lib/proxy/vm-registry.ts
|
|
7145
|
+
import fs4 from "fs";
|
|
7146
|
+
var DEFAULT_REGISTRY_PATH = "/tmp/vm0-vm-registry.json";
|
|
7147
|
+
var VMRegistry = class {
|
|
7148
|
+
registryPath;
|
|
7149
|
+
data;
|
|
7150
|
+
constructor(registryPath = DEFAULT_REGISTRY_PATH) {
|
|
7151
|
+
this.registryPath = registryPath;
|
|
7152
|
+
this.data = this.load();
|
|
7153
|
+
}
|
|
7154
|
+
/**
|
|
7155
|
+
* Load registry data from file
|
|
7156
|
+
*/
|
|
7157
|
+
load() {
|
|
7158
|
+
try {
|
|
7159
|
+
if (fs4.existsSync(this.registryPath)) {
|
|
7160
|
+
const content = fs4.readFileSync(this.registryPath, "utf-8");
|
|
7161
|
+
return JSON.parse(content);
|
|
7162
|
+
}
|
|
7163
|
+
} catch {
|
|
7164
|
+
}
|
|
7165
|
+
return { vms: {}, updatedAt: Date.now() };
|
|
7166
|
+
}
|
|
7167
|
+
/**
|
|
7168
|
+
* Save registry data to file atomically
|
|
7169
|
+
*/
|
|
7170
|
+
save() {
|
|
7171
|
+
this.data.updatedAt = Date.now();
|
|
7172
|
+
const content = JSON.stringify(this.data, null, 2);
|
|
7173
|
+
const tempPath = `${this.registryPath}.tmp`;
|
|
7174
|
+
fs4.writeFileSync(tempPath, content, { mode: 420 });
|
|
7175
|
+
fs4.renameSync(tempPath, this.registryPath);
|
|
7176
|
+
}
|
|
7177
|
+
/**
|
|
7178
|
+
* Register a VM with its IP address
|
|
7179
|
+
*/
|
|
7180
|
+
register(vmIp, runId, sandboxToken, options) {
|
|
7181
|
+
this.data.vms[vmIp] = {
|
|
7182
|
+
runId,
|
|
7183
|
+
sandboxToken,
|
|
7184
|
+
registeredAt: Date.now(),
|
|
7185
|
+
firewallRules: options?.firewallRules,
|
|
7186
|
+
mitmEnabled: options?.mitmEnabled,
|
|
7187
|
+
sealSecretsEnabled: options?.sealSecretsEnabled
|
|
7188
|
+
};
|
|
7189
|
+
this.save();
|
|
7190
|
+
const firewallInfo = options?.firewallRules ? ` with ${options.firewallRules.length} firewall rules` : "";
|
|
7191
|
+
const mitmInfo = options?.mitmEnabled ? ", MITM enabled" : "";
|
|
7192
|
+
console.log(
|
|
7193
|
+
`[VMRegistry] Registered VM ${vmIp} for run ${runId}${firewallInfo}${mitmInfo}`
|
|
7194
|
+
);
|
|
7195
|
+
}
|
|
7196
|
+
/**
|
|
7197
|
+
* Unregister a VM by IP address
|
|
7198
|
+
*/
|
|
7199
|
+
unregister(vmIp) {
|
|
7200
|
+
if (this.data.vms[vmIp]) {
|
|
7201
|
+
const registration = this.data.vms[vmIp];
|
|
7202
|
+
delete this.data.vms[vmIp];
|
|
7203
|
+
this.save();
|
|
7204
|
+
console.log(
|
|
7205
|
+
`[VMRegistry] Unregistered VM ${vmIp} (run ${registration.runId})`
|
|
7206
|
+
);
|
|
7207
|
+
}
|
|
7208
|
+
}
|
|
7209
|
+
/**
|
|
7210
|
+
* Look up registration by VM IP
|
|
7211
|
+
*/
|
|
7212
|
+
lookup(vmIp) {
|
|
7213
|
+
return this.data.vms[vmIp];
|
|
7214
|
+
}
|
|
7215
|
+
/**
|
|
7216
|
+
* Get all registered VMs
|
|
7217
|
+
*/
|
|
7218
|
+
getAll() {
|
|
7219
|
+
return { ...this.data.vms };
|
|
7220
|
+
}
|
|
7221
|
+
/**
|
|
7222
|
+
* Clear all registrations
|
|
7223
|
+
*/
|
|
7224
|
+
clear() {
|
|
7225
|
+
this.data.vms = {};
|
|
7226
|
+
this.save();
|
|
7227
|
+
console.log("[VMRegistry] Cleared all registrations");
|
|
7228
|
+
}
|
|
7229
|
+
/**
|
|
7230
|
+
* Get the path to the registry file
|
|
7231
|
+
*/
|
|
7232
|
+
getRegistryPath() {
|
|
7233
|
+
return this.registryPath;
|
|
7234
|
+
}
|
|
7235
|
+
};
|
|
7236
|
+
var globalRegistry = null;
|
|
7237
|
+
function getVMRegistry() {
|
|
7238
|
+
if (!globalRegistry) {
|
|
7239
|
+
globalRegistry = new VMRegistry();
|
|
7240
|
+
}
|
|
7241
|
+
return globalRegistry;
|
|
7242
|
+
}
|
|
7243
|
+
function initVMRegistry(registryPath) {
|
|
7244
|
+
globalRegistry = new VMRegistry(registryPath);
|
|
7245
|
+
return globalRegistry;
|
|
7246
|
+
}
|
|
7247
|
+
|
|
7248
|
+
// src/lib/proxy/proxy-manager.ts
|
|
7249
|
+
import { spawn as spawn2 } from "child_process";
|
|
7250
|
+
import fs5 from "fs";
|
|
7251
|
+
import path3 from "path";
|
|
7106
7252
|
|
|
7107
|
-
//
|
|
7108
|
-
var
|
|
7253
|
+
// src/lib/proxy/mitm-addon-script.ts
|
|
7254
|
+
var RUNNER_MITM_ADDON_SCRIPT = `#!/usr/bin/env python3
|
|
7109
7255
|
"""
|
|
7110
|
-
|
|
7111
|
-
|
|
7256
|
+
mitmproxy addon for VM0 runner-level network security mode.
|
|
7257
|
+
|
|
7258
|
+
This addon runs on the runner HOST (not inside VMs) and:
|
|
7259
|
+
1. Intercepts all HTTPS requests from VMs
|
|
7260
|
+
2. Looks up the source VM's runId and firewall rules from the VM registry
|
|
7261
|
+
3. Evaluates firewall rules (first-match-wins) to ALLOW or DENY
|
|
7262
|
+
4. For MITM mode: Rewrites requests to go through VM0 Proxy endpoint
|
|
7263
|
+
5. For SNI-only mode: Passes through or blocks without decryption
|
|
7264
|
+
6. Logs network activity per-run to JSONL files
|
|
7112
7265
|
"""
|
|
7113
7266
|
import os
|
|
7267
|
+
import json
|
|
7268
|
+
import time
|
|
7269
|
+
import urllib.parse
|
|
7270
|
+
import ipaddress
|
|
7271
|
+
import socket
|
|
7272
|
+
from mitmproxy import http, ctx, tls
|
|
7114
7273
|
|
|
7115
|
-
# Environment variables
|
|
7116
|
-
RUN_ID = os.environ.get("VM0_RUN_ID", "")
|
|
7117
|
-
API_URL = os.environ.get("VM0_API_URL", "")
|
|
7118
|
-
API_TOKEN = os.environ.get("VM0_API_TOKEN", "")
|
|
7119
|
-
PROMPT = os.environ.get("VM0_PROMPT", "")
|
|
7120
|
-
VERCEL_BYPASS = os.environ.get("VERCEL_PROTECTION_BYPASS", "")
|
|
7121
|
-
RESUME_SESSION_ID = os.environ.get("VM0_RESUME_SESSION_ID", "")
|
|
7122
|
-
|
|
7123
|
-
# CLI agent type - determines which CLI to invoke (claude-code or codex)
|
|
7124
|
-
CLI_AGENT_TYPE = os.environ.get("CLI_AGENT_TYPE", "claude-code")
|
|
7125
|
-
|
|
7126
|
-
# OpenAI model override - used for OpenRouter/custom endpoints with Codex
|
|
7127
|
-
OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "")
|
|
7128
|
-
|
|
7129
|
-
# Working directory is required - no fallback allowed
|
|
7130
|
-
WORKING_DIR = os.environ.get("VM0_WORKING_DIR", "")
|
|
7131
|
-
|
|
7132
|
-
# Artifact configuration (replaces GIT_VOLUMES and VM0_VOLUMES)
|
|
7133
|
-
ARTIFACT_DRIVER = os.environ.get("VM0_ARTIFACT_DRIVER", "")
|
|
7134
|
-
ARTIFACT_MOUNT_PATH = os.environ.get("VM0_ARTIFACT_MOUNT_PATH", "")
|
|
7135
|
-
ARTIFACT_VOLUME_NAME = os.environ.get("VM0_ARTIFACT_VOLUME_NAME", "")
|
|
7136
|
-
ARTIFACT_VERSION_ID = os.environ.get("VM0_ARTIFACT_VERSION_ID", "")
|
|
7137
|
-
|
|
7138
|
-
# Construct webhook endpoint URLs
|
|
7139
|
-
WEBHOOK_URL = f"{API_URL}/api/webhooks/agent/events"
|
|
7140
|
-
CHECKPOINT_URL = f"{API_URL}/api/webhooks/agent/checkpoints"
|
|
7141
|
-
COMPLETE_URL = f"{API_URL}/api/webhooks/agent/complete"
|
|
7142
|
-
HEARTBEAT_URL = f"{API_URL}/api/webhooks/agent/heartbeat"
|
|
7143
|
-
TELEMETRY_URL = f"{API_URL}/api/webhooks/agent/telemetry"
|
|
7144
|
-
PROXY_URL = f"{API_URL}/api/webhooks/agent/proxy"
|
|
7145
|
-
|
|
7146
|
-
# Direct S3 upload endpoints (webhook versions for sandbox - uses JWT auth)
|
|
7147
|
-
STORAGE_PREPARE_URL = f"{API_URL}/api/webhooks/agent/storages/prepare"
|
|
7148
|
-
STORAGE_COMMIT_URL = f"{API_URL}/api/webhooks/agent/storages/commit"
|
|
7149
7274
|
|
|
7150
|
-
#
|
|
7151
|
-
|
|
7275
|
+
# VM0 Proxy configuration from environment
|
|
7276
|
+
API_URL = os.environ.get("VM0_API_URL", "https://www.vm0.ai")
|
|
7277
|
+
REGISTRY_PATH = os.environ.get("VM0_REGISTRY_PATH", "/tmp/vm0-vm-registry.json")
|
|
7278
|
+
VERCEL_BYPASS = os.environ.get("VERCEL_AUTOMATION_BYPASS_SECRET", "")
|
|
7152
7279
|
|
|
7153
|
-
#
|
|
7154
|
-
|
|
7280
|
+
# Construct proxy URL
|
|
7281
|
+
PROXY_URL = f"{API_URL}/api/webhooks/agent/proxy"
|
|
7155
7282
|
|
|
7156
|
-
#
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
|
|
7160
|
-
HTTP_MAX_RETRIES = 3
|
|
7283
|
+
# Cache for VM registry (reloaded periodically)
|
|
7284
|
+
_registry_cache = {}
|
|
7285
|
+
_registry_cache_time = 0
|
|
7286
|
+
REGISTRY_CACHE_TTL = 2 # seconds
|
|
7161
7287
|
|
|
7162
|
-
#
|
|
7163
|
-
|
|
7164
|
-
SESSION_HISTORY_PATH_FILE = f"/tmp/vm0-session-history-{RUN_ID}.txt"
|
|
7288
|
+
# Track request start times for latency calculation
|
|
7289
|
+
request_start_times = {}
|
|
7165
7290
|
|
|
7166
|
-
# Event error flag file - used to track if any events failed to send
|
|
7167
|
-
EVENT_ERROR_FLAG = f"/tmp/vm0-event-error-{RUN_ID}"
|
|
7168
7291
|
|
|
7169
|
-
|
|
7170
|
-
|
|
7171
|
-
|
|
7292
|
+
def load_registry() -> dict:
|
|
7293
|
+
"""Load the VM registry from file, with caching."""
|
|
7294
|
+
global _registry_cache, _registry_cache_time
|
|
7172
7295
|
|
|
7173
|
-
|
|
7174
|
-
|
|
7296
|
+
now = time.time()
|
|
7297
|
+
if now - _registry_cache_time < REGISTRY_CACHE_TTL:
|
|
7298
|
+
return _registry_cache
|
|
7175
7299
|
|
|
7176
|
-
|
|
7177
|
-
|
|
7300
|
+
try:
|
|
7301
|
+
if os.path.exists(REGISTRY_PATH):
|
|
7302
|
+
with open(REGISTRY_PATH, "r") as f:
|
|
7303
|
+
data = json.load(f)
|
|
7304
|
+
_registry_cache = data.get("vms", {})
|
|
7305
|
+
_registry_cache_time = now
|
|
7306
|
+
return _registry_cache
|
|
7307
|
+
except Exception as e:
|
|
7308
|
+
ctx.log.warn(f"Failed to load VM registry: {e}")
|
|
7178
7309
|
|
|
7179
|
-
|
|
7180
|
-
TELEMETRY_LOG_POS_FILE = f"/tmp/vm0-telemetry-log-pos-{RUN_ID}.txt"
|
|
7181
|
-
TELEMETRY_METRICS_POS_FILE = f"/tmp/vm0-telemetry-metrics-pos-{RUN_ID}.txt"
|
|
7182
|
-
TELEMETRY_NETWORK_POS_FILE = f"/tmp/vm0-telemetry-network-pos-{RUN_ID}.txt"
|
|
7183
|
-
TELEMETRY_SANDBOX_OPS_POS_FILE = f"/tmp/vm0-telemetry-sandbox-ops-pos-{RUN_ID}.txt"
|
|
7310
|
+
return _registry_cache
|
|
7184
7311
|
|
|
7185
|
-
# Sandbox operations log file (JSONL format)
|
|
7186
|
-
SANDBOX_OPS_LOG_FILE = f"/tmp/vm0-sandbox-ops-{RUN_ID}.jsonl"
|
|
7187
7312
|
|
|
7188
|
-
|
|
7189
|
-
|
|
7313
|
+
def get_vm_info(client_ip: str) -> dict | None:
|
|
7314
|
+
"""Look up VM info by client IP address."""
|
|
7315
|
+
registry = load_registry()
|
|
7316
|
+
return registry.get(client_ip)
|
|
7190
7317
|
|
|
7191
|
-
def validate_config() -> bool:
|
|
7192
|
-
"""
|
|
7193
|
-
Validate required configuration.
|
|
7194
|
-
Raises ValueError if configuration is invalid.
|
|
7195
|
-
Returns True if valid.
|
|
7196
|
-
"""
|
|
7197
|
-
if not WORKING_DIR:
|
|
7198
|
-
raise ValueError("VM0_WORKING_DIR is required but not set")
|
|
7199
|
-
return True
|
|
7200
|
-
|
|
7201
|
-
def record_sandbox_op(
|
|
7202
|
-
action_type: str,
|
|
7203
|
-
duration_ms: int,
|
|
7204
|
-
success: bool,
|
|
7205
|
-
error: str = None
|
|
7206
|
-
) -> None:
|
|
7207
|
-
"""
|
|
7208
|
-
Record a sandbox operation to JSONL file for telemetry upload.
|
|
7209
7318
|
|
|
7210
|
-
|
|
7211
|
-
|
|
7212
|
-
|
|
7213
|
-
success: Whether the operation succeeded
|
|
7214
|
-
error: Optional error message if failed
|
|
7215
|
-
"""
|
|
7216
|
-
from datetime import datetime, timezone
|
|
7217
|
-
import json
|
|
7319
|
+
def get_network_log_path(run_id: str) -> str:
|
|
7320
|
+
"""Get the network log file path for a run."""
|
|
7321
|
+
return f"/tmp/vm0-network-{run_id}.jsonl"
|
|
7218
7322
|
|
|
7219
|
-
entry = {
|
|
7220
|
-
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
|
|
7221
|
-
"action_type": action_type,
|
|
7222
|
-
"duration_ms": duration_ms,
|
|
7223
|
-
"success": success,
|
|
7224
|
-
}
|
|
7225
|
-
if error:
|
|
7226
|
-
entry["error"] = error
|
|
7227
7323
|
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
|
|
7324
|
+
def log_network_entry(run_id: str, entry: dict) -> None:
|
|
7325
|
+
"""Write a network log entry to the per-run JSONL file."""
|
|
7326
|
+
if not run_id:
|
|
7327
|
+
return
|
|
7231
7328
|
|
|
7232
|
-
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
|
|
7236
|
-
|
|
7237
|
-
|
|
7238
|
-
|
|
7239
|
-
|
|
7240
|
-
|
|
7329
|
+
log_path = get_network_log_path(run_id)
|
|
7330
|
+
try:
|
|
7331
|
+
fd = os.open(log_path, os.O_CREAT | os.O_APPEND | os.O_WRONLY, 0o644)
|
|
7332
|
+
try:
|
|
7333
|
+
os.write(fd, (json.dumps(entry) + "\\n").encode())
|
|
7334
|
+
finally:
|
|
7335
|
+
os.close(fd)
|
|
7336
|
+
except Exception as e:
|
|
7337
|
+
ctx.log.warn(f"Failed to write network log: {e}")
|
|
7241
7338
|
|
|
7242
|
-
# Default script name, can be overridden by setting LOG_SCRIPT_NAME env var
|
|
7243
|
-
SCRIPT_NAME = os.environ.get("LOG_SCRIPT_NAME", "run-agent")
|
|
7244
|
-
DEBUG_MODE = os.environ.get("VM0_DEBUG", "") == "1"
|
|
7245
7339
|
|
|
7340
|
+
def get_original_url(flow: http.HTTPFlow) -> str:
|
|
7341
|
+
"""Reconstruct the original target URL from the request."""
|
|
7342
|
+
scheme = "https" if flow.request.port == 443 else "http"
|
|
7343
|
+
host = flow.request.pretty_host
|
|
7344
|
+
port = flow.request.port
|
|
7246
7345
|
|
|
7247
|
-
|
|
7248
|
-
|
|
7249
|
-
|
|
7346
|
+
if (scheme == "https" and port != 443) or (scheme == "http" and port != 80):
|
|
7347
|
+
host_with_port = f"{host}:{port}"
|
|
7348
|
+
else:
|
|
7349
|
+
host_with_port = host
|
|
7250
7350
|
|
|
7351
|
+
path = flow.request.path
|
|
7352
|
+
return f"{scheme}://{host_with_port}{path}"
|
|
7251
7353
|
|
|
7252
|
-
def log_info(msg: str) -> None:
|
|
7253
|
-
"""Log info message to stderr."""
|
|
7254
|
-
print(f"[{_timestamp()}] [INFO] [sandbox:{SCRIPT_NAME}] {msg}", file=sys.stderr)
|
|
7255
7354
|
|
|
7355
|
+
# ============================================================================
|
|
7356
|
+
# Firewall Rule Matching
|
|
7357
|
+
# ============================================================================
|
|
7256
7358
|
|
|
7257
|
-
def
|
|
7258
|
-
"""
|
|
7259
|
-
|
|
7359
|
+
def match_domain(pattern: str, hostname: str) -> bool:
|
|
7360
|
+
"""
|
|
7361
|
+
Match hostname against domain pattern.
|
|
7362
|
+
Supports exact match and wildcard prefix (*.example.com).
|
|
7363
|
+
"""
|
|
7364
|
+
if not pattern or not hostname:
|
|
7365
|
+
return False
|
|
7260
7366
|
|
|
7367
|
+
pattern = pattern.lower()
|
|
7368
|
+
hostname = hostname.lower()
|
|
7261
7369
|
|
|
7262
|
-
|
|
7263
|
-
|
|
7264
|
-
|
|
7370
|
+
if pattern.startswith("*."):
|
|
7371
|
+
# Wildcard: *.example.com matches sub.example.com, www.example.com
|
|
7372
|
+
# Also matches example.com itself (without subdomain)
|
|
7373
|
+
suffix = pattern[1:] # .example.com
|
|
7374
|
+
base = pattern[2:] # example.com
|
|
7375
|
+
return hostname.endswith(suffix) or hostname == base
|
|
7265
7376
|
|
|
7377
|
+
return hostname == pattern
|
|
7266
7378
|
|
|
7267
|
-
def log_debug(msg: str) -> None:
|
|
7268
|
-
"""Log debug message to stderr (only if VM0_DEBUG=1)."""
|
|
7269
|
-
if DEBUG_MODE:
|
|
7270
|
-
print(f"[{_timestamp()}] [DEBUG] [sandbox:{SCRIPT_NAME}] {msg}", file=sys.stderr)
|
|
7271
|
-
`;
|
|
7272
7379
|
|
|
7273
|
-
|
|
7274
|
-
var HTTP_SCRIPT = `#!/usr/bin/env python3
|
|
7275
|
-
"""
|
|
7276
|
-
Unified HTTP request functions for VM0 agent scripts.
|
|
7277
|
-
Uses urllib (standard library) with retry logic.
|
|
7278
|
-
"""
|
|
7279
|
-
import json
|
|
7280
|
-
import time
|
|
7281
|
-
import subprocess
|
|
7282
|
-
import os
|
|
7283
|
-
from urllib.request import Request, urlopen
|
|
7284
|
-
from urllib.error import HTTPError, URLError
|
|
7285
|
-
from typing import Optional, Dict, Any
|
|
7286
|
-
|
|
7287
|
-
from common import (
|
|
7288
|
-
API_TOKEN, VERCEL_BYPASS,
|
|
7289
|
-
HTTP_CONNECT_TIMEOUT, HTTP_MAX_TIME, HTTP_MAX_TIME_UPLOAD, HTTP_MAX_RETRIES
|
|
7290
|
-
)
|
|
7291
|
-
from log import log_debug, log_warn, log_error
|
|
7292
|
-
|
|
7293
|
-
|
|
7294
|
-
def http_post_json(
|
|
7295
|
-
url: str,
|
|
7296
|
-
data: Dict[str, Any],
|
|
7297
|
-
max_retries: int = HTTP_MAX_RETRIES
|
|
7298
|
-
) -> Optional[Dict[str, Any]]:
|
|
7299
|
-
"""
|
|
7300
|
-
HTTP POST with JSON body and retry logic.
|
|
7301
|
-
|
|
7302
|
-
Args:
|
|
7303
|
-
url: Target URL
|
|
7304
|
-
data: Dictionary to send as JSON
|
|
7305
|
-
max_retries: Maximum retry attempts
|
|
7306
|
-
|
|
7307
|
-
Returns:
|
|
7308
|
-
Response JSON as dict on success, None on failure
|
|
7309
|
-
"""
|
|
7310
|
-
headers = {
|
|
7311
|
-
"Content-Type": "application/json",
|
|
7312
|
-
"Authorization": f"Bearer {API_TOKEN}",
|
|
7313
|
-
}
|
|
7314
|
-
if VERCEL_BYPASS:
|
|
7315
|
-
headers["x-vercel-protection-bypass"] = VERCEL_BYPASS
|
|
7316
|
-
|
|
7317
|
-
body = json.dumps(data).encode("utf-8")
|
|
7318
|
-
|
|
7319
|
-
for attempt in range(1, max_retries + 1):
|
|
7320
|
-
log_debug(f"HTTP POST attempt {attempt}/{max_retries} to {url}")
|
|
7321
|
-
try:
|
|
7322
|
-
req = Request(url, data=body, headers=headers, method="POST")
|
|
7323
|
-
with urlopen(req, timeout=HTTP_MAX_TIME) as resp:
|
|
7324
|
-
response_body = resp.read().decode("utf-8")
|
|
7325
|
-
if response_body:
|
|
7326
|
-
return json.loads(response_body)
|
|
7327
|
-
return {}
|
|
7328
|
-
except HTTPError as e:
|
|
7329
|
-
log_warn(f"HTTP POST failed (attempt {attempt}/{max_retries}): HTTP {e.code}")
|
|
7330
|
-
if attempt < max_retries:
|
|
7331
|
-
time.sleep(1)
|
|
7332
|
-
except URLError as e:
|
|
7333
|
-
log_warn(f"HTTP POST failed (attempt {attempt}/{max_retries}): {e.reason}")
|
|
7334
|
-
if attempt < max_retries:
|
|
7335
|
-
time.sleep(1)
|
|
7336
|
-
except TimeoutError:
|
|
7337
|
-
log_warn(f"HTTP POST failed (attempt {attempt}/{max_retries}): Timeout")
|
|
7338
|
-
if attempt < max_retries:
|
|
7339
|
-
time.sleep(1)
|
|
7340
|
-
except Exception as e:
|
|
7341
|
-
log_warn(f"HTTP POST failed (attempt {attempt}/{max_retries}): {e}")
|
|
7342
|
-
if attempt < max_retries:
|
|
7343
|
-
time.sleep(1)
|
|
7344
|
-
|
|
7345
|
-
log_error(f"HTTP POST failed after {max_retries} attempts to {url}")
|
|
7346
|
-
return None
|
|
7347
|
-
|
|
7348
|
-
|
|
7349
|
-
def http_post_form(
|
|
7350
|
-
url: str,
|
|
7351
|
-
form_fields: Dict[str, str],
|
|
7352
|
-
file_path: Optional[str] = None,
|
|
7353
|
-
file_field: str = "file",
|
|
7354
|
-
max_retries: int = HTTP_MAX_RETRIES
|
|
7355
|
-
) -> Optional[Dict[str, Any]]:
|
|
7356
|
-
"""
|
|
7357
|
-
HTTP POST with multipart form data and retry logic.
|
|
7358
|
-
Uses curl for multipart uploads as urllib doesn't support it well.
|
|
7359
|
-
|
|
7360
|
-
Args:
|
|
7361
|
-
url: Target URL
|
|
7362
|
-
form_fields: Dictionary of form field name -> value
|
|
7363
|
-
file_path: Optional path to file to upload
|
|
7364
|
-
file_field: Form field name for the file
|
|
7365
|
-
max_retries: Maximum retry attempts
|
|
7366
|
-
|
|
7367
|
-
Returns:
|
|
7368
|
-
Response JSON as dict on success, None on failure
|
|
7369
|
-
"""
|
|
7370
|
-
for attempt in range(1, max_retries + 1):
|
|
7371
|
-
log_debug(f"HTTP POST form attempt {attempt}/{max_retries} to {url}")
|
|
7372
|
-
|
|
7373
|
-
# Build curl command
|
|
7374
|
-
# -f flag makes curl return non-zero exit code on HTTP 4xx/5xx errors
|
|
7375
|
-
curl_cmd = [
|
|
7376
|
-
"curl", "-f", "-X", "POST", url,
|
|
7377
|
-
"-H", f"Authorization: Bearer {API_TOKEN}",
|
|
7378
|
-
"--connect-timeout", str(HTTP_CONNECT_TIMEOUT),
|
|
7379
|
-
"--max-time", str(HTTP_MAX_TIME_UPLOAD),
|
|
7380
|
-
"--silent"
|
|
7381
|
-
]
|
|
7382
|
-
|
|
7383
|
-
if VERCEL_BYPASS:
|
|
7384
|
-
curl_cmd.extend(["-H", f"x-vercel-protection-bypass: {VERCEL_BYPASS}"])
|
|
7385
|
-
|
|
7386
|
-
# Add form fields
|
|
7387
|
-
for key, value in form_fields.items():
|
|
7388
|
-
curl_cmd.extend(["-F", f"{key}={value}"])
|
|
7389
|
-
|
|
7390
|
-
# Add file if provided
|
|
7391
|
-
if file_path:
|
|
7392
|
-
curl_cmd.extend(["-F", f"{file_field}=@{file_path}"])
|
|
7393
|
-
|
|
7394
|
-
result = None # Initialize for use in except blocks
|
|
7395
|
-
try:
|
|
7396
|
-
result = subprocess.run(
|
|
7397
|
-
curl_cmd,
|
|
7398
|
-
capture_output=True,
|
|
7399
|
-
text=True,
|
|
7400
|
-
timeout=HTTP_MAX_TIME_UPLOAD
|
|
7401
|
-
)
|
|
7402
|
-
|
|
7403
|
-
if result.returncode == 0:
|
|
7404
|
-
if result.stdout:
|
|
7405
|
-
return json.loads(result.stdout)
|
|
7406
|
-
return {}
|
|
7407
|
-
|
|
7408
|
-
# Log curl exit code and stderr for better debugging
|
|
7409
|
-
error_msg = f"curl exit {result.returncode}"
|
|
7410
|
-
if result.stderr:
|
|
7411
|
-
error_msg += f": {result.stderr.strip()}"
|
|
7412
|
-
log_warn(f"HTTP POST form failed (attempt {attempt}/{max_retries}): {error_msg}")
|
|
7413
|
-
if attempt < max_retries:
|
|
7414
|
-
time.sleep(1)
|
|
7415
|
-
|
|
7416
|
-
except subprocess.TimeoutExpired:
|
|
7417
|
-
log_warn(f"HTTP POST form failed (attempt {attempt}/{max_retries}): Timeout")
|
|
7418
|
-
if attempt < max_retries:
|
|
7419
|
-
time.sleep(1)
|
|
7420
|
-
except json.JSONDecodeError as e:
|
|
7421
|
-
log_warn(f"HTTP POST form failed (attempt {attempt}/{max_retries}): Invalid JSON response: {e}")
|
|
7422
|
-
# Log raw response for debugging (truncate to avoid log spam)
|
|
7423
|
-
if result and result.stdout:
|
|
7424
|
-
log_debug(f"Raw response: {result.stdout[:500]}")
|
|
7425
|
-
if attempt < max_retries:
|
|
7426
|
-
time.sleep(1)
|
|
7427
|
-
except Exception as e:
|
|
7428
|
-
log_warn(f"HTTP POST form failed (attempt {attempt}/{max_retries}): {e}")
|
|
7429
|
-
if attempt < max_retries:
|
|
7430
|
-
time.sleep(1)
|
|
7431
|
-
|
|
7432
|
-
log_error(f"HTTP POST form failed after {max_retries} attempts to {url}")
|
|
7433
|
-
return None
|
|
7434
|
-
|
|
7435
|
-
|
|
7436
|
-
def http_put_presigned(
|
|
7437
|
-
presigned_url: str,
|
|
7438
|
-
file_path: str,
|
|
7439
|
-
content_type: str = "application/octet-stream",
|
|
7440
|
-
max_retries: int = HTTP_MAX_RETRIES
|
|
7441
|
-
) -> bool:
|
|
7442
|
-
"""
|
|
7443
|
-
HTTP PUT to a presigned S3 URL with retry logic.
|
|
7444
|
-
Used for direct S3 uploads bypassing Vercel's 4.5MB limit.
|
|
7445
|
-
|
|
7446
|
-
Args:
|
|
7447
|
-
presigned_url: S3 presigned PUT URL
|
|
7448
|
-
file_path: Path to file to upload
|
|
7449
|
-
content_type: Content-Type header value
|
|
7450
|
-
max_retries: Maximum retry attempts
|
|
7451
|
-
|
|
7452
|
-
Returns:
|
|
7453
|
-
True on success, False on failure
|
|
7454
|
-
"""
|
|
7455
|
-
for attempt in range(1, max_retries + 1):
|
|
7456
|
-
log_debug(f"HTTP PUT presigned attempt {attempt}/{max_retries}")
|
|
7457
|
-
|
|
7458
|
-
try:
|
|
7459
|
-
# Use curl for reliable large file uploads
|
|
7460
|
-
curl_cmd = [
|
|
7461
|
-
"curl", "-f", "-X", "PUT",
|
|
7462
|
-
"-H", f"Content-Type: {content_type}",
|
|
7463
|
-
"--data-binary", f"@{file_path}",
|
|
7464
|
-
"--connect-timeout", str(HTTP_CONNECT_TIMEOUT),
|
|
7465
|
-
"--max-time", str(HTTP_MAX_TIME_UPLOAD),
|
|
7466
|
-
"--silent",
|
|
7467
|
-
presigned_url
|
|
7468
|
-
]
|
|
7469
|
-
|
|
7470
|
-
result = subprocess.run(
|
|
7471
|
-
curl_cmd,
|
|
7472
|
-
capture_output=True,
|
|
7473
|
-
text=True,
|
|
7474
|
-
timeout=HTTP_MAX_TIME_UPLOAD
|
|
7475
|
-
)
|
|
7476
|
-
|
|
7477
|
-
if result.returncode == 0:
|
|
7478
|
-
return True
|
|
7479
|
-
|
|
7480
|
-
error_msg = f"curl exit {result.returncode}"
|
|
7481
|
-
if result.stderr:
|
|
7482
|
-
error_msg += f": {result.stderr.strip()}"
|
|
7483
|
-
log_warn(f"HTTP PUT presigned failed (attempt {attempt}/{max_retries}): {error_msg}")
|
|
7484
|
-
if attempt < max_retries:
|
|
7485
|
-
time.sleep(1)
|
|
7486
|
-
|
|
7487
|
-
except subprocess.TimeoutExpired:
|
|
7488
|
-
log_warn(f"HTTP PUT presigned failed (attempt {attempt}/{max_retries}): Timeout")
|
|
7489
|
-
if attempt < max_retries:
|
|
7490
|
-
time.sleep(1)
|
|
7491
|
-
except Exception as e:
|
|
7492
|
-
log_warn(f"HTTP PUT presigned failed (attempt {attempt}/{max_retries}): {e}")
|
|
7493
|
-
if attempt < max_retries:
|
|
7494
|
-
time.sleep(1)
|
|
7495
|
-
|
|
7496
|
-
log_error(f"HTTP PUT presigned failed after {max_retries} attempts")
|
|
7497
|
-
return False
|
|
7498
|
-
|
|
7499
|
-
|
|
7500
|
-
def http_download(
|
|
7501
|
-
url: str,
|
|
7502
|
-
dest_path: str,
|
|
7503
|
-
max_retries: int = HTTP_MAX_RETRIES
|
|
7504
|
-
) -> bool:
|
|
7505
|
-
"""
|
|
7506
|
-
Download a file from URL with retry logic.
|
|
7507
|
-
|
|
7508
|
-
Args:
|
|
7509
|
-
url: Source URL
|
|
7510
|
-
dest_path: Destination file path
|
|
7511
|
-
max_retries: Maximum retry attempts
|
|
7512
|
-
|
|
7513
|
-
Returns:
|
|
7514
|
-
True on success, False on failure
|
|
7515
|
-
"""
|
|
7516
|
-
for attempt in range(1, max_retries + 1):
|
|
7517
|
-
log_debug(f"HTTP download attempt {attempt}/{max_retries} from {url}")
|
|
7518
|
-
|
|
7519
|
-
try:
|
|
7520
|
-
curl_cmd = [
|
|
7521
|
-
"curl", "-fsSL",
|
|
7522
|
-
"-o", dest_path,
|
|
7523
|
-
url
|
|
7524
|
-
]
|
|
7525
|
-
|
|
7526
|
-
result = subprocess.run(
|
|
7527
|
-
curl_cmd,
|
|
7528
|
-
capture_output=True,
|
|
7529
|
-
timeout=HTTP_MAX_TIME_UPLOAD
|
|
7530
|
-
)
|
|
7531
|
-
|
|
7532
|
-
if result.returncode == 0:
|
|
7533
|
-
return True
|
|
7534
|
-
|
|
7535
|
-
log_warn(f"HTTP download failed (attempt {attempt}/{max_retries}): curl exit {result.returncode}")
|
|
7536
|
-
if attempt < max_retries:
|
|
7537
|
-
time.sleep(1)
|
|
7538
|
-
|
|
7539
|
-
except subprocess.TimeoutExpired:
|
|
7540
|
-
log_warn(f"HTTP download failed (attempt {attempt}/{max_retries}): Timeout")
|
|
7541
|
-
if attempt < max_retries:
|
|
7542
|
-
time.sleep(1)
|
|
7543
|
-
except Exception as e:
|
|
7544
|
-
log_warn(f"HTTP download failed (attempt {attempt}/{max_retries}): {e}")
|
|
7545
|
-
if attempt < max_retries:
|
|
7546
|
-
time.sleep(1)
|
|
7547
|
-
|
|
7548
|
-
log_error(f"HTTP download failed after {max_retries} attempts from {url}")
|
|
7549
|
-
return False
|
|
7550
|
-
`;
|
|
7551
|
-
|
|
7552
|
-
// ../../packages/core/src/sandbox/scripts/lib/events.py.ts
|
|
7553
|
-
var EVENTS_SCRIPT = `#!/usr/bin/env python3
|
|
7554
|
-
"""
|
|
7555
|
-
Event sending module for VM0 agent scripts.
|
|
7556
|
-
Sends JSONL events to the webhook endpoint.
|
|
7557
|
-
Masks secrets before sending using client-side masking.
|
|
7558
|
-
"""
|
|
7559
|
-
import os
|
|
7560
|
-
from typing import Dict, Any
|
|
7561
|
-
|
|
7562
|
-
from common import (
|
|
7563
|
-
RUN_ID, WORKING_DIR, WEBHOOK_URL, CLI_AGENT_TYPE,
|
|
7564
|
-
SESSION_ID_FILE, SESSION_HISTORY_PATH_FILE, EVENT_ERROR_FLAG
|
|
7565
|
-
)
|
|
7566
|
-
from log import log_info, log_error
|
|
7567
|
-
from http_client import http_post_json
|
|
7568
|
-
from secret_masker import mask_data
|
|
7569
|
-
|
|
7570
|
-
|
|
7571
|
-
def send_event(event: Dict[str, Any], sequence_number: int) -> bool:
|
|
7572
|
-
"""
|
|
7573
|
-
Send single event immediately to webhook.
|
|
7574
|
-
Masks secrets before sending.
|
|
7575
|
-
|
|
7576
|
-
Args:
|
|
7577
|
-
event: Event dictionary to send
|
|
7578
|
-
sequence_number: Sequence number for this event (1-based, maintained by caller)
|
|
7579
|
-
|
|
7580
|
-
Returns:
|
|
7581
|
-
True on success, False on failure
|
|
7582
|
-
"""
|
|
7583
|
-
# Extract session ID from init event based on CLI agent type
|
|
7584
|
-
event_type = event.get("type", "")
|
|
7585
|
-
event_subtype = event.get("subtype", "")
|
|
7586
|
-
|
|
7587
|
-
# Claude Code: session_id from system/init event
|
|
7588
|
-
# Codex: thread_id from thread.started event
|
|
7589
|
-
session_id = None
|
|
7590
|
-
if CLI_AGENT_TYPE == "codex":
|
|
7591
|
-
if event_type == "thread.started":
|
|
7592
|
-
session_id = event.get("thread_id", "")
|
|
7593
|
-
else:
|
|
7594
|
-
if event_type == "system" and event_subtype == "init":
|
|
7595
|
-
session_id = event.get("session_id", "")
|
|
7596
|
-
|
|
7597
|
-
if session_id and not os.path.exists(SESSION_ID_FILE):
|
|
7598
|
-
log_info(f"Captured session ID: {session_id}")
|
|
7599
|
-
|
|
7600
|
-
# Save to temp file to persist across subprocesses
|
|
7601
|
-
with open(SESSION_ID_FILE, "w") as f:
|
|
7602
|
-
f.write(session_id)
|
|
7603
|
-
|
|
7604
|
-
# Calculate session history path based on CLI agent type
|
|
7605
|
-
home_dir = os.environ.get("HOME", "/home/user")
|
|
7606
|
-
|
|
7607
|
-
if CLI_AGENT_TYPE == "codex":
|
|
7608
|
-
# Codex stores sessions in ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
|
|
7609
|
-
# We'll store a marker path here; checkpoint.py will search for the actual file
|
|
7610
|
-
codex_home = os.environ.get("CODEX_HOME", f"{home_dir}/.codex")
|
|
7611
|
-
# Use special marker format that checkpoint.py will recognize
|
|
7612
|
-
session_history_path = f"CODEX_SEARCH:{codex_home}/sessions:{session_id}"
|
|
7613
|
-
else:
|
|
7614
|
-
# Claude Code uses ~/.claude (default, no CLAUDE_CONFIG_DIR override)
|
|
7615
|
-
# Path encoding: e.g., /home/user/workspace -> -home-user-workspace
|
|
7616
|
-
project_name = WORKING_DIR.lstrip("/").replace("/", "-")
|
|
7617
|
-
session_history_path = f"{home_dir}/.claude/projects/-{project_name}/{session_id}.jsonl"
|
|
7618
|
-
|
|
7619
|
-
with open(SESSION_HISTORY_PATH_FILE, "w") as f:
|
|
7620
|
-
f.write(session_history_path)
|
|
7621
|
-
|
|
7622
|
-
log_info(f"Session history will be at: {session_history_path}")
|
|
7623
|
-
|
|
7624
|
-
# Add sequence number to event
|
|
7625
|
-
event["sequenceNumber"] = sequence_number
|
|
7626
|
-
|
|
7627
|
-
# Mask secrets in event data before sending
|
|
7628
|
-
# This ensures secrets are never sent to the server in plaintext
|
|
7629
|
-
masked_event = mask_data(event)
|
|
7630
|
-
|
|
7631
|
-
# Build payload with masked event
|
|
7632
|
-
payload = {
|
|
7633
|
-
"runId": RUN_ID,
|
|
7634
|
-
"events": [masked_event]
|
|
7635
|
-
}
|
|
7636
|
-
|
|
7637
|
-
# Send event using HTTP request function
|
|
7638
|
-
result = http_post_json(WEBHOOK_URL, payload)
|
|
7639
|
-
|
|
7640
|
-
if result is None:
|
|
7641
|
-
log_error("Failed to send event after retries")
|
|
7642
|
-
# Mark that event sending failed - run-agent will check this
|
|
7643
|
-
with open(EVENT_ERROR_FLAG, "w") as f:
|
|
7644
|
-
f.write("1")
|
|
7645
|
-
return False
|
|
7646
|
-
|
|
7647
|
-
return True
|
|
7648
|
-
`;
|
|
7649
|
-
|
|
7650
|
-
// ../../packages/core/src/sandbox/scripts/lib/direct_upload.py.ts
|
|
7651
|
-
var DIRECT_UPLOAD_SCRIPT = `#!/usr/bin/env python3
|
|
7652
|
-
"""
|
|
7653
|
-
Direct S3 upload module for VAS (Versioned Artifact Storage).
|
|
7654
|
-
Bypasses Vercel's 4.5MB request body limit by uploading directly to S3.
|
|
7655
|
-
|
|
7656
|
-
Flow:
|
|
7657
|
-
1. Compute file hashes locally
|
|
7658
|
-
2. Call /api/storages/prepare to get presigned URLs
|
|
7659
|
-
3. Upload archive and manifest directly to S3
|
|
7660
|
-
4. Call /api/storages/commit to finalize
|
|
7661
|
-
"""
|
|
7662
|
-
import os
|
|
7663
|
-
import json
|
|
7664
|
-
import hashlib
|
|
7665
|
-
import tarfile
|
|
7666
|
-
import tempfile
|
|
7667
|
-
import shutil
|
|
7668
|
-
import time
|
|
7669
|
-
from typing import Optional, Dict, Any, List
|
|
7670
|
-
from datetime import datetime
|
|
7671
|
-
|
|
7672
|
-
from common import RUN_ID, STORAGE_PREPARE_URL, STORAGE_COMMIT_URL, record_sandbox_op
|
|
7673
|
-
from log import log_info, log_warn, log_error, log_debug
|
|
7674
|
-
from http_client import http_post_json, http_put_presigned
|
|
7675
|
-
|
|
7676
|
-
|
|
7677
|
-
def compute_file_hash(file_path: str) -> str:
|
|
7678
|
-
"""Compute SHA-256 hash for a file."""
|
|
7679
|
-
sha256_hash = hashlib.sha256()
|
|
7680
|
-
with open(file_path, "rb") as f:
|
|
7681
|
-
for byte_block in iter(lambda: f.read(4096), b""):
|
|
7682
|
-
sha256_hash.update(byte_block)
|
|
7683
|
-
return sha256_hash.hexdigest()
|
|
7684
|
-
|
|
7685
|
-
|
|
7686
|
-
def collect_file_metadata(dir_path: str) -> List[Dict[str, Any]]:
|
|
7687
|
-
"""
|
|
7688
|
-
Collect file metadata with hashes for a directory.
|
|
7689
|
-
|
|
7690
|
-
Args:
|
|
7691
|
-
dir_path: Directory to scan
|
|
7692
|
-
|
|
7693
|
-
Returns:
|
|
7694
|
-
List of file entries: [{path, hash, size}, ...]
|
|
7695
|
-
"""
|
|
7696
|
-
files = []
|
|
7697
|
-
original_dir = os.getcwd()
|
|
7698
|
-
|
|
7699
|
-
try:
|
|
7700
|
-
os.chdir(dir_path)
|
|
7701
|
-
|
|
7702
|
-
for root, dirs, filenames in os.walk("."):
|
|
7703
|
-
# Exclude .git and .vm0 directories
|
|
7704
|
-
dirs[:] = [d for d in dirs if d not in (".git", ".vm0")]
|
|
7705
|
-
|
|
7706
|
-
for filename in filenames:
|
|
7707
|
-
rel_path = os.path.join(root, filename)
|
|
7708
|
-
# Remove leading ./
|
|
7709
|
-
if rel_path.startswith("./"):
|
|
7710
|
-
rel_path = rel_path[2:]
|
|
7711
|
-
|
|
7712
|
-
full_path = os.path.join(dir_path, rel_path)
|
|
7713
|
-
try:
|
|
7714
|
-
file_hash = compute_file_hash(full_path)
|
|
7715
|
-
file_size = os.path.getsize(full_path)
|
|
7716
|
-
files.append({
|
|
7717
|
-
"path": rel_path,
|
|
7718
|
-
"hash": file_hash,
|
|
7719
|
-
"size": file_size
|
|
7720
|
-
})
|
|
7721
|
-
except (IOError, OSError) as e:
|
|
7722
|
-
log_warn(f"Could not process file {rel_path}: {e}")
|
|
7723
|
-
|
|
7724
|
-
finally:
|
|
7725
|
-
os.chdir(original_dir)
|
|
7726
|
-
|
|
7727
|
-
return files
|
|
7728
|
-
|
|
7729
|
-
|
|
7730
|
-
def create_archive(dir_path: str, tar_path: str) -> bool:
|
|
7731
|
-
"""
|
|
7732
|
-
Create tar.gz archive of directory contents.
|
|
7733
|
-
|
|
7734
|
-
Args:
|
|
7735
|
-
dir_path: Source directory
|
|
7736
|
-
tar_path: Destination tar.gz path
|
|
7737
|
-
|
|
7738
|
-
Returns:
|
|
7739
|
-
True on success, False on failure
|
|
7740
|
-
"""
|
|
7741
|
-
original_dir = os.getcwd()
|
|
7742
|
-
|
|
7743
|
-
try:
|
|
7744
|
-
os.chdir(dir_path)
|
|
7745
|
-
|
|
7746
|
-
# Get files to archive (exclude .git and .vm0)
|
|
7747
|
-
items = [item for item in os.listdir(".") if item not in (".git", ".vm0")]
|
|
7748
|
-
|
|
7749
|
-
with tarfile.open(tar_path, "w:gz") as tar:
|
|
7750
|
-
for item in items:
|
|
7751
|
-
tar.add(item)
|
|
7752
|
-
|
|
7753
|
-
return True
|
|
7754
|
-
|
|
7755
|
-
except Exception as e:
|
|
7756
|
-
log_error(f"Failed to create archive: {e}")
|
|
7757
|
-
return False
|
|
7758
|
-
|
|
7759
|
-
finally:
|
|
7760
|
-
os.chdir(original_dir)
|
|
7761
|
-
|
|
7762
|
-
|
|
7763
|
-
def create_manifest(files: List[Dict[str, Any]], manifest_path: str) -> bool:
|
|
7764
|
-
"""
|
|
7765
|
-
Create manifest JSON file.
|
|
7766
|
-
|
|
7767
|
-
Args:
|
|
7768
|
-
files: List of file entries
|
|
7769
|
-
manifest_path: Destination path for manifest
|
|
7770
|
-
|
|
7771
|
-
Returns:
|
|
7772
|
-
True on success, False on failure
|
|
7773
|
-
"""
|
|
7774
|
-
try:
|
|
7775
|
-
manifest = {
|
|
7776
|
-
"version": 1,
|
|
7777
|
-
"files": files,
|
|
7778
|
-
"createdAt": datetime.utcnow().isoformat() + "Z"
|
|
7779
|
-
}
|
|
7780
|
-
with open(manifest_path, "w") as f:
|
|
7781
|
-
json.dump(manifest, f, indent=2)
|
|
7782
|
-
return True
|
|
7783
|
-
except Exception as e:
|
|
7784
|
-
log_error(f"Failed to create manifest: {e}")
|
|
7785
|
-
return False
|
|
7786
|
-
|
|
7787
|
-
|
|
7788
|
-
def create_direct_upload_snapshot(
|
|
7789
|
-
mount_path: str,
|
|
7790
|
-
storage_name: str,
|
|
7791
|
-
storage_type: str = "artifact",
|
|
7792
|
-
run_id: str = None,
|
|
7793
|
-
message: str = None
|
|
7794
|
-
) -> Optional[Dict[str, Any]]:
|
|
7795
|
-
"""
|
|
7796
|
-
Create VAS snapshot using direct S3 upload.
|
|
7797
|
-
Bypasses Vercel's 4.5MB request body limit.
|
|
7798
|
-
|
|
7799
|
-
Args:
|
|
7800
|
-
mount_path: Path to the storage directory
|
|
7801
|
-
storage_name: VAS storage name
|
|
7802
|
-
storage_type: Storage type ("volume" or "artifact")
|
|
7803
|
-
run_id: Optional run ID for sandbox auth
|
|
7804
|
-
message: Optional commit message
|
|
7805
|
-
|
|
7806
|
-
Returns:
|
|
7807
|
-
Dict with versionId on success, None on failure
|
|
7808
|
-
"""
|
|
7809
|
-
log_info(f"Creating direct upload snapshot for '{storage_name}' (type: {storage_type})")
|
|
7810
|
-
|
|
7811
|
-
# Step 1: Collect file metadata
|
|
7812
|
-
log_info("Computing file hashes...")
|
|
7813
|
-
hash_start = time.time()
|
|
7814
|
-
files = collect_file_metadata(mount_path)
|
|
7815
|
-
record_sandbox_op("artifact_hash_compute", int((time.time() - hash_start) * 1000), True)
|
|
7816
|
-
log_info(f"Found {len(files)} files")
|
|
7817
|
-
|
|
7818
|
-
if not files:
|
|
7819
|
-
log_info("No files to upload, creating empty version")
|
|
7820
|
-
|
|
7821
|
-
# Step 2: Call prepare endpoint
|
|
7822
|
-
log_info("Calling prepare endpoint...")
|
|
7823
|
-
prepare_start = time.time()
|
|
7824
|
-
prepare_payload = {
|
|
7825
|
-
"storageName": storage_name,
|
|
7826
|
-
"storageType": storage_type,
|
|
7827
|
-
"files": files
|
|
7828
|
-
}
|
|
7829
|
-
if run_id:
|
|
7830
|
-
prepare_payload["runId"] = run_id
|
|
7831
|
-
|
|
7832
|
-
prepare_response = http_post_json(STORAGE_PREPARE_URL, prepare_payload)
|
|
7833
|
-
if not prepare_response:
|
|
7834
|
-
log_error("Failed to call prepare endpoint")
|
|
7835
|
-
record_sandbox_op("artifact_prepare_api", int((time.time() - prepare_start) * 1000), False)
|
|
7836
|
-
return None
|
|
7837
|
-
|
|
7838
|
-
version_id = prepare_response.get("versionId")
|
|
7839
|
-
if not version_id:
|
|
7840
|
-
log_error(f"Invalid prepare response: {prepare_response}")
|
|
7841
|
-
record_sandbox_op("artifact_prepare_api", int((time.time() - prepare_start) * 1000), False)
|
|
7842
|
-
return None
|
|
7843
|
-
record_sandbox_op("artifact_prepare_api", int((time.time() - prepare_start) * 1000), True)
|
|
7844
|
-
|
|
7845
|
-
# Step 3: Check if version already exists (deduplication)
|
|
7846
|
-
# Still call commit to update HEAD pointer (fixes #649)
|
|
7847
|
-
if prepare_response.get("existing"):
|
|
7848
|
-
log_info(f"Version already exists (deduplicated): {version_id[:8]}")
|
|
7849
|
-
log_info("Updating HEAD pointer...")
|
|
7850
|
-
|
|
7851
|
-
commit_payload = {
|
|
7852
|
-
"storageName": storage_name,
|
|
7853
|
-
"storageType": storage_type,
|
|
7854
|
-
"versionId": version_id,
|
|
7855
|
-
"files": files
|
|
7856
|
-
}
|
|
7857
|
-
if run_id:
|
|
7858
|
-
commit_payload["runId"] = run_id
|
|
7859
|
-
|
|
7860
|
-
commit_response = http_post_json(STORAGE_COMMIT_URL, commit_payload)
|
|
7861
|
-
if not commit_response or not commit_response.get("success"):
|
|
7862
|
-
log_error(f"Failed to update HEAD: {commit_response}")
|
|
7863
|
-
return None
|
|
7864
|
-
|
|
7865
|
-
return {"versionId": version_id, "deduplicated": True}
|
|
7866
|
-
|
|
7867
|
-
# Step 4: Get presigned URLs
|
|
7868
|
-
uploads = prepare_response.get("uploads")
|
|
7869
|
-
if not uploads:
|
|
7870
|
-
log_error("No upload URLs in prepare response")
|
|
7871
|
-
return None
|
|
7872
|
-
|
|
7873
|
-
archive_info = uploads.get("archive")
|
|
7874
|
-
manifest_info = uploads.get("manifest")
|
|
7875
|
-
|
|
7876
|
-
if not archive_info or not manifest_info:
|
|
7877
|
-
log_error("Missing archive or manifest upload info")
|
|
7878
|
-
return None
|
|
7879
|
-
|
|
7880
|
-
# Step 5: Create and upload files
|
|
7881
|
-
temp_dir = tempfile.mkdtemp(prefix=f"direct-upload-{storage_name}-")
|
|
7882
|
-
|
|
7883
|
-
try:
|
|
7884
|
-
# Create archive
|
|
7885
|
-
log_info("Creating archive...")
|
|
7886
|
-
archive_start = time.time()
|
|
7887
|
-
archive_path = os.path.join(temp_dir, "archive.tar.gz")
|
|
7888
|
-
if not create_archive(mount_path, archive_path):
|
|
7889
|
-
log_error("Failed to create archive")
|
|
7890
|
-
record_sandbox_op("artifact_archive_create", int((time.time() - archive_start) * 1000), False)
|
|
7891
|
-
return None
|
|
7892
|
-
record_sandbox_op("artifact_archive_create", int((time.time() - archive_start) * 1000), True)
|
|
7893
|
-
|
|
7894
|
-
# Create manifest
|
|
7895
|
-
log_info("Creating manifest...")
|
|
7896
|
-
manifest_path = os.path.join(temp_dir, "manifest.json")
|
|
7897
|
-
if not create_manifest(files, manifest_path):
|
|
7898
|
-
log_error("Failed to create manifest")
|
|
7899
|
-
return None
|
|
7900
|
-
|
|
7901
|
-
# Upload archive to S3
|
|
7902
|
-
log_info("Uploading archive to S3...")
|
|
7903
|
-
s3_upload_start = time.time()
|
|
7904
|
-
if not http_put_presigned(
|
|
7905
|
-
archive_info["presignedUrl"],
|
|
7906
|
-
archive_path,
|
|
7907
|
-
"application/gzip"
|
|
7908
|
-
):
|
|
7909
|
-
log_error("Failed to upload archive to S3")
|
|
7910
|
-
record_sandbox_op("artifact_s3_upload", int((time.time() - s3_upload_start) * 1000), False)
|
|
7911
|
-
return None
|
|
7912
|
-
|
|
7913
|
-
# Upload manifest to S3
|
|
7914
|
-
log_info("Uploading manifest to S3...")
|
|
7915
|
-
if not http_put_presigned(
|
|
7916
|
-
manifest_info["presignedUrl"],
|
|
7917
|
-
manifest_path,
|
|
7918
|
-
"application/json"
|
|
7919
|
-
):
|
|
7920
|
-
log_error("Failed to upload manifest to S3")
|
|
7921
|
-
record_sandbox_op("artifact_s3_upload", int((time.time() - s3_upload_start) * 1000), False)
|
|
7922
|
-
return None
|
|
7923
|
-
record_sandbox_op("artifact_s3_upload", int((time.time() - s3_upload_start) * 1000), True)
|
|
7924
|
-
|
|
7925
|
-
# Step 6: Call commit endpoint
|
|
7926
|
-
log_info("Calling commit endpoint...")
|
|
7927
|
-
commit_start = time.time()
|
|
7928
|
-
commit_payload = {
|
|
7929
|
-
"storageName": storage_name,
|
|
7930
|
-
"storageType": storage_type,
|
|
7931
|
-
"versionId": version_id,
|
|
7932
|
-
"files": files
|
|
7933
|
-
}
|
|
7934
|
-
if run_id:
|
|
7935
|
-
commit_payload["runId"] = run_id
|
|
7936
|
-
if message:
|
|
7937
|
-
commit_payload["message"] = message
|
|
7938
|
-
|
|
7939
|
-
commit_response = http_post_json(STORAGE_COMMIT_URL, commit_payload)
|
|
7940
|
-
if not commit_response:
|
|
7941
|
-
log_error("Failed to call commit endpoint")
|
|
7942
|
-
record_sandbox_op("artifact_commit_api", int((time.time() - commit_start) * 1000), False)
|
|
7943
|
-
return None
|
|
7944
|
-
|
|
7945
|
-
if not commit_response.get("success"):
|
|
7946
|
-
log_error(f"Commit failed: {commit_response}")
|
|
7947
|
-
record_sandbox_op("artifact_commit_api", int((time.time() - commit_start) * 1000), False)
|
|
7948
|
-
return None
|
|
7949
|
-
record_sandbox_op("artifact_commit_api", int((time.time() - commit_start) * 1000), True)
|
|
7950
|
-
|
|
7951
|
-
log_info(f"Direct upload snapshot created: {version_id[:8]}")
|
|
7952
|
-
return {"versionId": version_id}
|
|
7953
|
-
|
|
7954
|
-
finally:
|
|
7955
|
-
# Cleanup temp files
|
|
7956
|
-
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
7957
|
-
`;
|
|
7958
|
-
|
|
7959
|
-
// ../../packages/core/src/sandbox/scripts/lib/download.py.ts
|
|
7960
|
-
var DOWNLOAD_SCRIPT = `#!/usr/bin/env python3
|
|
7961
|
-
"""
|
|
7962
|
-
Download storages script for E2B sandbox.
|
|
7963
|
-
Downloads tar.gz archives directly from S3 using presigned URLs.
|
|
7964
|
-
|
|
7965
|
-
Usage: python download.py <manifest_path>
|
|
7966
|
-
"""
|
|
7967
|
-
import os
|
|
7968
|
-
import sys
|
|
7969
|
-
import json
|
|
7970
|
-
import tarfile
|
|
7971
|
-
import tempfile
|
|
7972
|
-
import time
|
|
7973
|
-
|
|
7974
|
-
# Add lib to path for imports
|
|
7975
|
-
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
7976
|
-
|
|
7977
|
-
from common import validate_config, record_sandbox_op
|
|
7978
|
-
from log import log_info, log_error
|
|
7979
|
-
from http_client import http_download
|
|
7980
|
-
|
|
7981
|
-
|
|
7982
|
-
def download_storage(mount_path: str, archive_url: str) -> bool:
|
|
7983
|
-
"""
|
|
7984
|
-
Download and extract a single storage/artifact.
|
|
7985
|
-
|
|
7986
|
-
Args:
|
|
7987
|
-
mount_path: Destination mount path
|
|
7988
|
-
archive_url: Presigned S3 URL for tar.gz archive
|
|
7989
|
-
|
|
7990
|
-
Returns:
|
|
7991
|
-
True on success, False on failure
|
|
7992
|
-
"""
|
|
7993
|
-
log_info(f"Downloading storage to {mount_path}")
|
|
7994
|
-
|
|
7995
|
-
# Create temp file for download
|
|
7996
|
-
temp_tar = tempfile.mktemp(suffix=".tar.gz", prefix="storage-")
|
|
7997
|
-
|
|
7998
|
-
try:
|
|
7999
|
-
# Download tar.gz with retry
|
|
8000
|
-
if not http_download(archive_url, temp_tar):
|
|
8001
|
-
log_error(f"Failed to download archive for {mount_path}")
|
|
8002
|
-
return False
|
|
8003
|
-
|
|
8004
|
-
# Create mount path directory
|
|
8005
|
-
os.makedirs(mount_path, exist_ok=True)
|
|
8006
|
-
|
|
8007
|
-
# Extract to mount path (handle empty archive gracefully)
|
|
8008
|
-
try:
|
|
8009
|
-
with tarfile.open(temp_tar, "r:gz") as tar:
|
|
8010
|
-
tar.extractall(path=mount_path)
|
|
8011
|
-
except tarfile.ReadError:
|
|
8012
|
-
# Empty or invalid archive - not a fatal error
|
|
8013
|
-
log_info(f"Archive appears empty for {mount_path}")
|
|
8014
|
-
|
|
8015
|
-
log_info(f"Successfully extracted to {mount_path}")
|
|
8016
|
-
return True
|
|
8017
|
-
|
|
8018
|
-
finally:
|
|
8019
|
-
# Cleanup temp file
|
|
8020
|
-
try:
|
|
8021
|
-
os.remove(temp_tar)
|
|
8022
|
-
except OSError:
|
|
8023
|
-
pass
|
|
8024
|
-
|
|
8025
|
-
|
|
8026
|
-
def main():
|
|
8027
|
-
"""Main entry point for download storages script."""
|
|
8028
|
-
if len(sys.argv) < 2:
|
|
8029
|
-
log_error("Usage: python download.py <manifest_path>")
|
|
8030
|
-
sys.exit(1)
|
|
8031
|
-
|
|
8032
|
-
manifest_path = sys.argv[1]
|
|
8033
|
-
|
|
8034
|
-
if not os.path.exists(manifest_path):
|
|
8035
|
-
log_error(f"Manifest file not found: {manifest_path}")
|
|
8036
|
-
sys.exit(1)
|
|
8037
|
-
|
|
8038
|
-
log_info(f"Starting storage download from manifest: {manifest_path}")
|
|
8039
|
-
|
|
8040
|
-
# Load manifest
|
|
8041
|
-
try:
|
|
8042
|
-
with open(manifest_path) as f:
|
|
8043
|
-
manifest = json.load(f)
|
|
8044
|
-
except (IOError, json.JSONDecodeError) as e:
|
|
8045
|
-
log_error(f"Failed to load manifest: {e}")
|
|
8046
|
-
sys.exit(1)
|
|
8047
|
-
|
|
8048
|
-
# Count total storages
|
|
8049
|
-
storages = manifest.get("storages", [])
|
|
8050
|
-
artifact = manifest.get("artifact")
|
|
8051
|
-
|
|
8052
|
-
storage_count = len(storages)
|
|
8053
|
-
has_artifact = artifact is not None
|
|
8054
|
-
|
|
8055
|
-
log_info(f"Found {storage_count} storages, artifact: {has_artifact}")
|
|
8056
|
-
|
|
8057
|
-
# Track total download time
|
|
8058
|
-
download_total_start = time.time()
|
|
8059
|
-
download_success = True
|
|
8060
|
-
|
|
8061
|
-
# Process storages
|
|
8062
|
-
for storage in storages:
|
|
8063
|
-
mount_path = storage.get("mountPath")
|
|
8064
|
-
archive_url = storage.get("archiveUrl")
|
|
8065
|
-
|
|
8066
|
-
if archive_url and archive_url != "null":
|
|
8067
|
-
storage_start = time.time()
|
|
8068
|
-
success = download_storage(mount_path, archive_url)
|
|
8069
|
-
record_sandbox_op("storage_download", int((time.time() - storage_start) * 1000), success)
|
|
8070
|
-
if not success:
|
|
8071
|
-
download_success = False
|
|
8072
|
-
|
|
8073
|
-
# Process artifact
|
|
8074
|
-
if artifact:
|
|
8075
|
-
artifact_mount = artifact.get("mountPath")
|
|
8076
|
-
artifact_url = artifact.get("archiveUrl")
|
|
8077
|
-
|
|
8078
|
-
if artifact_url and artifact_url != "null":
|
|
8079
|
-
artifact_start = time.time()
|
|
8080
|
-
success = download_storage(artifact_mount, artifact_url)
|
|
8081
|
-
record_sandbox_op("artifact_download", int((time.time() - artifact_start) * 1000), success)
|
|
8082
|
-
if not success:
|
|
8083
|
-
download_success = False
|
|
8084
|
-
|
|
8085
|
-
# Record total download time
|
|
8086
|
-
record_sandbox_op("download_total", int((time.time() - download_total_start) * 1000), download_success)
|
|
8087
|
-
log_info("All storages downloaded successfully")
|
|
8088
|
-
|
|
8089
|
-
|
|
8090
|
-
if __name__ == "__main__":
|
|
8091
|
-
main()
|
|
8092
|
-
`;
|
|
8093
|
-
|
|
8094
|
-
// ../../packages/core/src/sandbox/scripts/lib/checkpoint.py.ts
|
|
8095
|
-
var CHECKPOINT_SCRIPT = `#!/usr/bin/env python3
|
|
8096
|
-
"""
|
|
8097
|
-
Checkpoint creation module.
|
|
8098
|
-
Creates checkpoints with conversation history and optional artifact snapshot (VAS only).
|
|
8099
|
-
Uses direct S3 upload exclusively (no fallback to legacy methods).
|
|
8100
|
-
"""
|
|
8101
|
-
import os
|
|
8102
|
-
import glob
|
|
8103
|
-
import time
|
|
8104
|
-
from typing import Optional, Dict, Any
|
|
8105
|
-
|
|
8106
|
-
from common import (
|
|
8107
|
-
RUN_ID, CHECKPOINT_URL,
|
|
8108
|
-
SESSION_ID_FILE, SESSION_HISTORY_PATH_FILE,
|
|
8109
|
-
ARTIFACT_DRIVER, ARTIFACT_MOUNT_PATH, ARTIFACT_VOLUME_NAME,
|
|
8110
|
-
record_sandbox_op
|
|
8111
|
-
)
|
|
8112
|
-
from log import log_info, log_error
|
|
8113
|
-
from http_client import http_post_json
|
|
8114
|
-
from direct_upload import create_direct_upload_snapshot
|
|
8115
|
-
|
|
8116
|
-
|
|
8117
|
-
def find_codex_session_file(sessions_dir: str, session_id: str) -> Optional[str]:
|
|
8118
|
-
"""
|
|
8119
|
-
Find Codex session file by searching in date-organized directories.
|
|
8120
|
-
Codex stores sessions in: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
|
|
8121
|
-
|
|
8122
|
-
Args:
|
|
8123
|
-
sessions_dir: Base sessions directory (e.g., ~/.codex/sessions)
|
|
8124
|
-
session_id: Session ID to find (e.g., 019b3aca-2df2-7573-8f88-4240b7bc350a)
|
|
8125
|
-
|
|
8126
|
-
Returns:
|
|
8127
|
-
Full path to session file, or None if not found
|
|
8128
|
-
"""
|
|
8129
|
-
# Search for session file containing the session ID
|
|
8130
|
-
# Pattern: sessions/YYYY/MM/DD/rollout-*-{session_id_parts}.jsonl
|
|
8131
|
-
# The session ID parts may be separated by dashes in the filename
|
|
8132
|
-
|
|
8133
|
-
# First, try searching all JSONL files recursively
|
|
8134
|
-
search_pattern = os.path.join(sessions_dir, "**", "*.jsonl")
|
|
8135
|
-
files = glob.glob(search_pattern, recursive=True)
|
|
8136
|
-
|
|
8137
|
-
log_info(f"Searching for Codex session {session_id} in {len(files)} files")
|
|
8138
|
-
|
|
8139
|
-
# The session ID in Codex filenames uses the format with dashes
|
|
8140
|
-
# e.g., rollout-2025-12-20T08-04-44-019b3aca-2df2-7573-8f88-4240b7bc350a.jsonl
|
|
8141
|
-
for filepath in files:
|
|
8142
|
-
filename = os.path.basename(filepath)
|
|
8143
|
-
# Check if session ID is in the filename
|
|
8144
|
-
if session_id in filename or session_id.replace("-", "") in filename.replace("-", ""):
|
|
8145
|
-
log_info(f"Found Codex session file: {filepath}")
|
|
8146
|
-
return filepath
|
|
8147
|
-
|
|
8148
|
-
# If not found by ID match, get the most recent file (fallback)
|
|
8149
|
-
if files:
|
|
8150
|
-
# Sort by modification time, newest first
|
|
8151
|
-
files.sort(key=lambda x: os.path.getmtime(x), reverse=True)
|
|
8152
|
-
most_recent = files[0]
|
|
8153
|
-
log_info(f"Session ID not found in filenames, using most recent: {most_recent}")
|
|
8154
|
-
return most_recent
|
|
8155
|
-
|
|
8156
|
-
return None
|
|
8157
|
-
|
|
8158
|
-
|
|
8159
|
-
def create_checkpoint() -> bool:
|
|
8160
|
-
"""
|
|
8161
|
-
Create checkpoint after successful run.
|
|
8162
|
-
|
|
8163
|
-
Returns:
|
|
8164
|
-
True on success, False on failure
|
|
8165
|
-
"""
|
|
8166
|
-
checkpoint_start = time.time()
|
|
8167
|
-
log_info("Creating checkpoint...")
|
|
8168
|
-
|
|
8169
|
-
# Read session ID from temp file
|
|
8170
|
-
session_id_start = time.time()
|
|
8171
|
-
if not os.path.exists(SESSION_ID_FILE):
|
|
8172
|
-
log_error("No session ID found, checkpoint creation failed")
|
|
8173
|
-
record_sandbox_op("session_id_read", int((time.time() - session_id_start) * 1000), False, "Session ID file not found")
|
|
8174
|
-
record_sandbox_op("checkpoint_total", int((time.time() - checkpoint_start) * 1000), False)
|
|
8175
|
-
return False
|
|
8176
|
-
|
|
8177
|
-
with open(SESSION_ID_FILE) as f:
|
|
8178
|
-
cli_agent_session_id = f.read().strip()
|
|
8179
|
-
record_sandbox_op("session_id_read", int((time.time() - session_id_start) * 1000), True)
|
|
8180
|
-
|
|
8181
|
-
# Read session history path from temp file
|
|
8182
|
-
session_history_start = time.time()
|
|
8183
|
-
if not os.path.exists(SESSION_HISTORY_PATH_FILE):
|
|
8184
|
-
log_error("No session history path found, checkpoint creation failed")
|
|
8185
|
-
record_sandbox_op("session_history_read", int((time.time() - session_history_start) * 1000), False, "Session history path file not found")
|
|
8186
|
-
record_sandbox_op("checkpoint_total", int((time.time() - checkpoint_start) * 1000), False)
|
|
8187
|
-
return False
|
|
8188
|
-
|
|
8189
|
-
with open(SESSION_HISTORY_PATH_FILE) as f:
|
|
8190
|
-
session_history_path_raw = f.read().strip()
|
|
8191
|
-
|
|
8192
|
-
# Handle Codex session search marker format: CODEX_SEARCH:{sessions_dir}:{session_id}
|
|
8193
|
-
if session_history_path_raw.startswith("CODEX_SEARCH:"):
|
|
8194
|
-
parts = session_history_path_raw.split(":", 2)
|
|
8195
|
-
if len(parts) != 3:
|
|
8196
|
-
log_error(f"Invalid Codex search marker format: {session_history_path_raw}")
|
|
8197
|
-
record_sandbox_op("session_history_read", int((time.time() - session_history_start) * 1000), False, "Invalid Codex search marker")
|
|
8198
|
-
record_sandbox_op("checkpoint_total", int((time.time() - checkpoint_start) * 1000), False)
|
|
8199
|
-
return False
|
|
8200
|
-
sessions_dir = parts[1]
|
|
8201
|
-
codex_session_id = parts[2]
|
|
8202
|
-
log_info(f"Searching for Codex session in {sessions_dir}")
|
|
8203
|
-
session_history_path = find_codex_session_file(sessions_dir, codex_session_id)
|
|
8204
|
-
if not session_history_path:
|
|
8205
|
-
log_error(f"Could not find Codex session file for {codex_session_id} in {sessions_dir}")
|
|
8206
|
-
record_sandbox_op("session_history_read", int((time.time() - session_history_start) * 1000), False, "Codex session file not found")
|
|
8207
|
-
record_sandbox_op("checkpoint_total", int((time.time() - checkpoint_start) * 1000), False)
|
|
8208
|
-
return False
|
|
8209
|
-
else:
|
|
8210
|
-
session_history_path = session_history_path_raw
|
|
8211
|
-
|
|
8212
|
-
# Check if session history file exists
|
|
8213
|
-
if not os.path.exists(session_history_path):
|
|
8214
|
-
log_error(f"Session history file not found at {session_history_path}, checkpoint creation failed")
|
|
8215
|
-
record_sandbox_op("session_history_read", int((time.time() - session_history_start) * 1000), False, "Session history file not found")
|
|
8216
|
-
record_sandbox_op("checkpoint_total", int((time.time() - checkpoint_start) * 1000), False)
|
|
8217
|
-
return False
|
|
8218
|
-
|
|
8219
|
-
# Read session history
|
|
8220
|
-
try:
|
|
8221
|
-
with open(session_history_path) as f:
|
|
8222
|
-
cli_agent_session_history = f.read()
|
|
8223
|
-
except IOError as e:
|
|
8224
|
-
log_error(f"Failed to read session history: {e}")
|
|
8225
|
-
record_sandbox_op("session_history_read", int((time.time() - session_history_start) * 1000), False, str(e))
|
|
8226
|
-
record_sandbox_op("checkpoint_total", int((time.time() - checkpoint_start) * 1000), False)
|
|
8227
|
-
return False
|
|
8228
|
-
|
|
8229
|
-
if not cli_agent_session_history.strip():
|
|
8230
|
-
log_error("Session history is empty, checkpoint creation failed")
|
|
8231
|
-
record_sandbox_op("session_history_read", int((time.time() - session_history_start) * 1000), False, "Session history empty")
|
|
8232
|
-
record_sandbox_op("checkpoint_total", int((time.time() - checkpoint_start) * 1000), False)
|
|
8233
|
-
return False
|
|
8234
|
-
|
|
8235
|
-
line_count = len(cli_agent_session_history.strip().split("\\n"))
|
|
8236
|
-
log_info(f"Session history loaded ({line_count} lines)")
|
|
8237
|
-
record_sandbox_op("session_history_read", int((time.time() - session_history_start) * 1000), True)
|
|
8238
|
-
|
|
8239
|
-
# CLI agent type (default to claude-code)
|
|
8240
|
-
cli_agent_type = os.environ.get("CLI_AGENT_TYPE", "claude-code")
|
|
8241
|
-
|
|
8242
|
-
# Create artifact snapshot (VAS only, optional)
|
|
8243
|
-
# If artifact is not configured, checkpoint is created without artifact snapshot
|
|
8244
|
-
artifact_snapshot = None
|
|
8245
|
-
|
|
8246
|
-
if ARTIFACT_DRIVER and ARTIFACT_VOLUME_NAME:
|
|
8247
|
-
log_info(f"Processing artifact with driver: {ARTIFACT_DRIVER}")
|
|
8248
|
-
|
|
8249
|
-
if ARTIFACT_DRIVER != "vas":
|
|
8250
|
-
log_error(f"Unknown artifact driver: {ARTIFACT_DRIVER} (only 'vas' is supported)")
|
|
8251
|
-
record_sandbox_op("checkpoint_total", int((time.time() - checkpoint_start) * 1000), False)
|
|
8252
|
-
return False
|
|
8253
|
-
|
|
8254
|
-
# VAS artifact: create snapshot using direct S3 upload (bypasses Vercel 4.5MB limit)
|
|
8255
|
-
log_info(f"Creating VAS snapshot for artifact '{ARTIFACT_VOLUME_NAME}' at {ARTIFACT_MOUNT_PATH}")
|
|
8256
|
-
log_info("Using direct S3 upload...")
|
|
8257
|
-
|
|
8258
|
-
snapshot = create_direct_upload_snapshot(
|
|
8259
|
-
ARTIFACT_MOUNT_PATH,
|
|
8260
|
-
ARTIFACT_VOLUME_NAME,
|
|
8261
|
-
"artifact",
|
|
8262
|
-
RUN_ID,
|
|
8263
|
-
f"Checkpoint from run {RUN_ID}"
|
|
8264
|
-
)
|
|
8265
|
-
|
|
8266
|
-
if not snapshot:
|
|
8267
|
-
log_error("Failed to create VAS snapshot for artifact")
|
|
8268
|
-
record_sandbox_op("checkpoint_total", int((time.time() - checkpoint_start) * 1000), False)
|
|
8269
|
-
return False
|
|
8270
|
-
|
|
8271
|
-
# Extract versionId from snapshot response
|
|
8272
|
-
artifact_version = snapshot.get("versionId")
|
|
8273
|
-
if not artifact_version:
|
|
8274
|
-
log_error("Failed to extract versionId from snapshot")
|
|
8275
|
-
record_sandbox_op("checkpoint_total", int((time.time() - checkpoint_start) * 1000), False)
|
|
8276
|
-
return False
|
|
8277
|
-
|
|
8278
|
-
# Build artifact snapshot JSON with new format (artifactName + artifactVersion)
|
|
8279
|
-
artifact_snapshot = {
|
|
8280
|
-
"artifactName": ARTIFACT_VOLUME_NAME,
|
|
8281
|
-
"artifactVersion": artifact_version
|
|
8282
|
-
}
|
|
8283
|
-
|
|
8284
|
-
log_info(f"VAS artifact snapshot created: {ARTIFACT_VOLUME_NAME}@{artifact_version}")
|
|
8285
|
-
else:
|
|
8286
|
-
log_info("No artifact configured, creating checkpoint without artifact snapshot")
|
|
8287
|
-
|
|
8288
|
-
log_info("Calling checkpoint API...")
|
|
8289
|
-
|
|
8290
|
-
# Build checkpoint payload with new schema
|
|
8291
|
-
checkpoint_payload = {
|
|
8292
|
-
"runId": RUN_ID,
|
|
8293
|
-
"cliAgentType": cli_agent_type,
|
|
8294
|
-
"cliAgentSessionId": cli_agent_session_id,
|
|
8295
|
-
"cliAgentSessionHistory": cli_agent_session_history
|
|
8296
|
-
}
|
|
8297
|
-
|
|
8298
|
-
# Only add artifact snapshot if present
|
|
8299
|
-
if artifact_snapshot:
|
|
8300
|
-
checkpoint_payload["artifactSnapshot"] = artifact_snapshot
|
|
8301
|
-
|
|
8302
|
-
# Call checkpoint API
|
|
8303
|
-
api_call_start = time.time()
|
|
8304
|
-
result = http_post_json(CHECKPOINT_URL, checkpoint_payload)
|
|
8305
|
-
|
|
8306
|
-
# Validate response contains checkpointId to confirm checkpoint was actually created
|
|
8307
|
-
# Note: result can be {} (empty dict) on network issues, which is not None but invalid
|
|
8308
|
-
if result and result.get("checkpointId"):
|
|
8309
|
-
checkpoint_id = result.get("checkpointId")
|
|
8310
|
-
log_info(f"Checkpoint created successfully: {checkpoint_id}")
|
|
8311
|
-
record_sandbox_op("checkpoint_api_call", int((time.time() - api_call_start) * 1000), True)
|
|
8312
|
-
record_sandbox_op("checkpoint_total", int((time.time() - checkpoint_start) * 1000), True)
|
|
8313
|
-
return True
|
|
8314
|
-
else:
|
|
8315
|
-
log_error(f"Checkpoint API returned invalid response: {result}")
|
|
8316
|
-
record_sandbox_op("checkpoint_api_call", int((time.time() - api_call_start) * 1000), False, "Invalid API response")
|
|
8317
|
-
record_sandbox_op("checkpoint_total", int((time.time() - checkpoint_start) * 1000), False)
|
|
8318
|
-
return False
|
|
8319
|
-
`;
|
|
8320
|
-
|
|
8321
|
-
// ../../packages/core/src/sandbox/scripts/lib/mock_claude.py.ts
|
|
8322
|
-
var MOCK_CLAUDE_SCRIPT = `#!/usr/bin/env python3
|
|
8323
|
-
"""
|
|
8324
|
-
Mock Claude CLI for testing.
|
|
8325
|
-
Executes prompt as bash and outputs Claude-compatible JSONL.
|
|
8326
|
-
|
|
8327
|
-
Usage: mock_claude.py [options] <prompt>
|
|
8328
|
-
The prompt is executed as a bash command.
|
|
8329
|
-
|
|
8330
|
-
Special test prefixes:
|
|
8331
|
-
@fail:<message> - Output message to stderr and exit with code 1
|
|
8332
|
-
"""
|
|
8333
|
-
import os
|
|
8334
|
-
import sys
|
|
8335
|
-
import json
|
|
8336
|
-
import subprocess
|
|
8337
|
-
import time
|
|
8338
|
-
import argparse
|
|
8339
|
-
|
|
8340
|
-
|
|
8341
|
-
def json_escape(s: str) -> str:
|
|
8342
|
-
"""Escape string for JSON."""
|
|
8343
|
-
return json.dumps(s)
|
|
8344
|
-
|
|
8345
|
-
|
|
8346
|
-
def create_session_history(session_id: str, cwd: str) -> str:
|
|
8347
|
-
"""
|
|
8348
|
-
Create session history file for checkpoint compatibility.
|
|
8349
|
-
Claude Code stores session history at: ~/.claude/projects/-{path}/{session_id}.jsonl
|
|
8350
|
-
"""
|
|
8351
|
-
project_name = cwd.lstrip("/").replace("/", "-")
|
|
8352
|
-
home_dir = os.environ.get("HOME", "/home/user")
|
|
8353
|
-
session_dir = f"{home_dir}/.claude/projects/-{project_name}"
|
|
8354
|
-
os.makedirs(session_dir, exist_ok=True)
|
|
8355
|
-
return f"{session_dir}/{session_id}.jsonl"
|
|
8356
|
-
|
|
8357
|
-
|
|
8358
|
-
def main():
|
|
8359
|
-
"""Main entry point for mock Claude."""
|
|
8360
|
-
# Generate session ID
|
|
8361
|
-
session_id = f"mock-{int(time.time() * 1000000)}"
|
|
8362
|
-
|
|
8363
|
-
# Parse arguments (same as real claude CLI)
|
|
8364
|
-
parser = argparse.ArgumentParser(add_help=False)
|
|
8365
|
-
parser.add_argument("--output-format", default="text")
|
|
8366
|
-
parser.add_argument("--print", action="store_true")
|
|
8367
|
-
parser.add_argument("--verbose", action="store_true")
|
|
8368
|
-
parser.add_argument("--dangerously-skip-permissions", action="store_true")
|
|
8369
|
-
parser.add_argument("--resume", default=None)
|
|
8370
|
-
parser.add_argument("prompt", nargs="?", default="")
|
|
8371
|
-
|
|
8372
|
-
args, unknown = parser.parse_known_args()
|
|
8373
|
-
|
|
8374
|
-
# Get prompt from remaining args if not set
|
|
8375
|
-
prompt = args.prompt
|
|
8376
|
-
if not prompt and unknown:
|
|
8377
|
-
prompt = unknown[0]
|
|
8378
|
-
|
|
8379
|
-
output_format = args.output_format
|
|
8380
|
-
|
|
8381
|
-
# Special test prefix: @fail:<message> - simulate Claude failure with stderr output
|
|
8382
|
-
# Usage: mock-claude "@fail:Session not found"
|
|
8383
|
-
# This outputs the message to stderr and exits with code 1
|
|
8384
|
-
if prompt.startswith("@fail:"):
|
|
8385
|
-
error_msg = prompt[6:] # Remove "@fail:" prefix
|
|
8386
|
-
print(error_msg, file=sys.stderr)
|
|
8387
|
-
sys.exit(1)
|
|
8388
|
-
|
|
8389
|
-
# Get current working directory
|
|
8390
|
-
cwd = os.getcwd()
|
|
8391
|
-
|
|
8392
|
-
if output_format == "stream-json":
|
|
8393
|
-
# Create session history file path
|
|
8394
|
-
session_history_file = create_session_history(session_id, cwd)
|
|
8395
|
-
|
|
8396
|
-
events = []
|
|
8397
|
-
|
|
8398
|
-
# 1. System init event
|
|
8399
|
-
init_event = {
|
|
8400
|
-
"type": "system",
|
|
8401
|
-
"subtype": "init",
|
|
8402
|
-
"cwd": cwd,
|
|
8403
|
-
"session_id": session_id,
|
|
8404
|
-
"tools": ["Bash"],
|
|
8405
|
-
"model": "mock-claude"
|
|
8406
|
-
}
|
|
8407
|
-
print(json.dumps(init_event))
|
|
8408
|
-
events.append(init_event)
|
|
8409
|
-
|
|
8410
|
-
# 2. Assistant text event
|
|
8411
|
-
text_event = {
|
|
8412
|
-
"type": "assistant",
|
|
8413
|
-
"message": {
|
|
8414
|
-
"role": "assistant",
|
|
8415
|
-
"content": [{"type": "text", "text": "Executing command..."}]
|
|
8416
|
-
},
|
|
8417
|
-
"session_id": session_id
|
|
8418
|
-
}
|
|
8419
|
-
print(json.dumps(text_event))
|
|
8420
|
-
events.append(text_event)
|
|
8421
|
-
|
|
8422
|
-
# 3. Assistant tool_use event
|
|
8423
|
-
tool_use_event = {
|
|
8424
|
-
"type": "assistant",
|
|
8425
|
-
"message": {
|
|
8426
|
-
"role": "assistant",
|
|
8427
|
-
"content": [{
|
|
8428
|
-
"type": "tool_use",
|
|
8429
|
-
"id": "toolu_mock_001",
|
|
8430
|
-
"name": "Bash",
|
|
8431
|
-
"input": {"command": prompt}
|
|
8432
|
-
}]
|
|
8433
|
-
},
|
|
8434
|
-
"session_id": session_id
|
|
8435
|
-
}
|
|
8436
|
-
print(json.dumps(tool_use_event))
|
|
8437
|
-
events.append(tool_use_event)
|
|
8438
|
-
|
|
8439
|
-
# 4. Execute prompt as bash and capture output
|
|
8440
|
-
try:
|
|
8441
|
-
result = subprocess.run(
|
|
8442
|
-
["bash", "-c", prompt],
|
|
8443
|
-
capture_output=True,
|
|
8444
|
-
text=True
|
|
8445
|
-
)
|
|
8446
|
-
output = result.stdout + result.stderr
|
|
8447
|
-
exit_code = result.returncode
|
|
8448
|
-
except Exception as e:
|
|
8449
|
-
output = str(e)
|
|
8450
|
-
exit_code = 1
|
|
8451
|
-
|
|
8452
|
-
# 5. User tool_result event
|
|
8453
|
-
is_error = exit_code != 0
|
|
8454
|
-
tool_result_event = {
|
|
8455
|
-
"type": "user",
|
|
8456
|
-
"message": {
|
|
8457
|
-
"role": "user",
|
|
8458
|
-
"content": [{
|
|
8459
|
-
"type": "tool_result",
|
|
8460
|
-
"tool_use_id": "toolu_mock_001",
|
|
8461
|
-
"content": output,
|
|
8462
|
-
"is_error": is_error
|
|
8463
|
-
}]
|
|
8464
|
-
},
|
|
8465
|
-
"session_id": session_id
|
|
8466
|
-
}
|
|
8467
|
-
print(json.dumps(tool_result_event))
|
|
8468
|
-
events.append(tool_result_event)
|
|
8469
|
-
|
|
8470
|
-
# 6. Result event
|
|
8471
|
-
if exit_code == 0:
|
|
8472
|
-
result_event = {
|
|
8473
|
-
"type": "result",
|
|
8474
|
-
"subtype": "success",
|
|
8475
|
-
"is_error": False,
|
|
8476
|
-
"duration_ms": 100,
|
|
8477
|
-
"num_turns": 1,
|
|
8478
|
-
"result": output,
|
|
8479
|
-
"session_id": session_id,
|
|
8480
|
-
"total_cost_usd": 0,
|
|
8481
|
-
"usage": {"input_tokens": 0, "output_tokens": 0}
|
|
8482
|
-
}
|
|
8483
|
-
else:
|
|
8484
|
-
result_event = {
|
|
8485
|
-
"type": "result",
|
|
8486
|
-
"subtype": "error",
|
|
8487
|
-
"is_error": True,
|
|
8488
|
-
"duration_ms": 100,
|
|
8489
|
-
"num_turns": 1,
|
|
8490
|
-
"result": output,
|
|
8491
|
-
"session_id": session_id,
|
|
8492
|
-
"total_cost_usd": 0,
|
|
8493
|
-
"usage": {"input_tokens": 0, "output_tokens": 0}
|
|
8494
|
-
}
|
|
8495
|
-
print(json.dumps(result_event))
|
|
8496
|
-
events.append(result_event)
|
|
8497
|
-
|
|
8498
|
-
# Write all events to session history file
|
|
8499
|
-
with open(session_history_file, "w") as f:
|
|
8500
|
-
for event in events:
|
|
8501
|
-
f.write(json.dumps(event) + "\\n")
|
|
8502
|
-
|
|
8503
|
-
sys.exit(exit_code)
|
|
8504
|
-
|
|
8505
|
-
else:
|
|
8506
|
-
# Plain text output - just execute the prompt
|
|
8507
|
-
try:
|
|
8508
|
-
result = subprocess.run(
|
|
8509
|
-
["bash", "-c", prompt],
|
|
8510
|
-
capture_output=False
|
|
8511
|
-
)
|
|
8512
|
-
sys.exit(result.returncode)
|
|
8513
|
-
except Exception as e:
|
|
8514
|
-
print(str(e), file=sys.stderr)
|
|
8515
|
-
sys.exit(1)
|
|
8516
|
-
|
|
8517
|
-
|
|
8518
|
-
if __name__ == "__main__":
|
|
8519
|
-
main()
|
|
8520
|
-
`;
|
|
8521
|
-
|
|
8522
|
-
// ../../packages/core/src/sandbox/scripts/lib/metrics.py.ts
|
|
8523
|
-
var METRICS_SCRIPT = `#!/usr/bin/env python3
|
|
8524
|
-
"""
|
|
8525
|
-
Metrics collection module for VM0 sandbox.
|
|
8526
|
-
Collects system resource metrics (CPU, memory, disk) and writes to JSONL file.
|
|
8527
|
-
"""
|
|
8528
|
-
import json
|
|
8529
|
-
import subprocess
|
|
8530
|
-
import threading
|
|
8531
|
-
from datetime import datetime, timezone
|
|
8532
|
-
|
|
8533
|
-
from common import METRICS_LOG_FILE, METRICS_INTERVAL
|
|
8534
|
-
from log import log_info, log_error, log_debug
|
|
8535
|
-
|
|
8536
|
-
|
|
8537
|
-
def get_cpu_percent() -> float:
|
|
8538
|
-
"""
|
|
8539
|
-
Get CPU usage percentage by parsing /proc/stat.
|
|
8540
|
-
Returns the CPU usage as a percentage (0-100).
|
|
8541
|
-
"""
|
|
8542
|
-
try:
|
|
8543
|
-
with open("/proc/stat", "r") as f:
|
|
8544
|
-
line = f.readline()
|
|
8545
|
-
|
|
8546
|
-
# cpu user nice system idle iowait irq softirq steal guest guest_nice
|
|
8547
|
-
parts = line.split()
|
|
8548
|
-
if parts[0] != "cpu":
|
|
8549
|
-
return 0.0
|
|
8550
|
-
|
|
8551
|
-
values = [int(x) for x in parts[1:]]
|
|
8552
|
-
idle = values[3] + values[4] # idle + iowait
|
|
8553
|
-
total = sum(values)
|
|
8554
|
-
|
|
8555
|
-
# Store for next calculation (we need delta)
|
|
8556
|
-
# For simplicity, just return instantaneous value based on idle ratio
|
|
8557
|
-
# This gives a rough estimate; for accurate CPU%, we'd need to track deltas
|
|
8558
|
-
if total == 0:
|
|
8559
|
-
return 0.0
|
|
8560
|
-
|
|
8561
|
-
cpu_percent = 100.0 * (1.0 - idle / total)
|
|
8562
|
-
return round(cpu_percent, 2)
|
|
8563
|
-
except Exception as e:
|
|
8564
|
-
log_debug(f"Failed to get CPU percent: {e}")
|
|
8565
|
-
return 0.0
|
|
8566
|
-
|
|
8567
|
-
|
|
8568
|
-
def get_memory_info() -> tuple[int, int]:
|
|
8569
|
-
"""
|
|
8570
|
-
Get memory usage using 'free -b' command.
|
|
8571
|
-
Returns (used, total) in bytes.
|
|
8572
|
-
"""
|
|
8573
|
-
try:
|
|
8574
|
-
result = subprocess.run(
|
|
8575
|
-
["free", "-b"],
|
|
8576
|
-
capture_output=True,
|
|
8577
|
-
text=True,
|
|
8578
|
-
timeout=5
|
|
8579
|
-
)
|
|
8580
|
-
|
|
8581
|
-
if result.returncode != 0:
|
|
8582
|
-
return (0, 0)
|
|
8583
|
-
|
|
8584
|
-
# Parse output:
|
|
8585
|
-
# Mem: total used free shared buff/cache available
|
|
8586
|
-
lines = result.stdout.strip().split("\\n")
|
|
8587
|
-
for line in lines:
|
|
8588
|
-
if line.startswith("Mem:"):
|
|
8589
|
-
parts = line.split()
|
|
8590
|
-
total = int(parts[1])
|
|
8591
|
-
used = int(parts[2])
|
|
8592
|
-
return (used, total)
|
|
8593
|
-
|
|
8594
|
-
return (0, 0)
|
|
8595
|
-
except Exception as e:
|
|
8596
|
-
log_debug(f"Failed to get memory info: {e}")
|
|
8597
|
-
return (0, 0)
|
|
8598
|
-
|
|
8599
|
-
|
|
8600
|
-
def get_disk_info() -> tuple[int, int]:
|
|
8601
|
-
"""
|
|
8602
|
-
Get disk usage using 'df -B1 /' command.
|
|
8603
|
-
Returns (used, total) in bytes.
|
|
8604
|
-
"""
|
|
8605
|
-
try:
|
|
8606
|
-
result = subprocess.run(
|
|
8607
|
-
["df", "-B1", "/"],
|
|
8608
|
-
capture_output=True,
|
|
8609
|
-
text=True,
|
|
8610
|
-
timeout=5
|
|
8611
|
-
)
|
|
8612
|
-
|
|
8613
|
-
if result.returncode != 0:
|
|
8614
|
-
return (0, 0)
|
|
8615
|
-
|
|
8616
|
-
# Parse output:
|
|
8617
|
-
# Filesystem 1B-blocks Used Available Use% Mounted
|
|
8618
|
-
lines = result.stdout.strip().split("\\n")
|
|
8619
|
-
if len(lines) < 2:
|
|
8620
|
-
return (0, 0)
|
|
8621
|
-
|
|
8622
|
-
# Skip header, parse data line
|
|
8623
|
-
parts = lines[1].split()
|
|
8624
|
-
total = int(parts[1])
|
|
8625
|
-
used = int(parts[2])
|
|
8626
|
-
return (used, total)
|
|
8627
|
-
except Exception as e:
|
|
8628
|
-
log_debug(f"Failed to get disk info: {e}")
|
|
8629
|
-
return (0, 0)
|
|
8630
|
-
|
|
8631
|
-
|
|
8632
|
-
def collect_metrics() -> dict:
|
|
8633
|
-
"""
|
|
8634
|
-
Collect all system metrics and return as a dictionary.
|
|
8635
|
-
"""
|
|
8636
|
-
cpu = get_cpu_percent()
|
|
8637
|
-
mem_used, mem_total = get_memory_info()
|
|
8638
|
-
disk_used, disk_total = get_disk_info()
|
|
8639
|
-
|
|
8640
|
-
return {
|
|
8641
|
-
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
8642
|
-
"cpu": cpu,
|
|
8643
|
-
"mem_used": mem_used,
|
|
8644
|
-
"mem_total": mem_total,
|
|
8645
|
-
"disk_used": disk_used,
|
|
8646
|
-
"disk_total": disk_total
|
|
8647
|
-
}
|
|
8648
|
-
|
|
8649
|
-
|
|
8650
|
-
def metrics_collector_loop(shutdown_event: threading.Event) -> None:
|
|
8651
|
-
"""
|
|
8652
|
-
Background loop that collects metrics every METRICS_INTERVAL seconds.
|
|
8653
|
-
Writes metrics as JSONL to METRICS_LOG_FILE.
|
|
8654
|
-
"""
|
|
8655
|
-
log_info(f"Metrics collector started, writing to {METRICS_LOG_FILE}")
|
|
8656
|
-
|
|
8657
|
-
try:
|
|
8658
|
-
with open(METRICS_LOG_FILE, "a") as f:
|
|
8659
|
-
while not shutdown_event.is_set():
|
|
8660
|
-
try:
|
|
8661
|
-
metrics = collect_metrics()
|
|
8662
|
-
f.write(json.dumps(metrics) + "\\n")
|
|
8663
|
-
f.flush()
|
|
8664
|
-
log_debug(f"Metrics collected: cpu={metrics['cpu']}%, mem={metrics['mem_used']}/{metrics['mem_total']}")
|
|
8665
|
-
except Exception as e:
|
|
8666
|
-
log_error(f"Failed to collect/write metrics: {e}")
|
|
8667
|
-
|
|
8668
|
-
# Wait for interval or shutdown
|
|
8669
|
-
shutdown_event.wait(METRICS_INTERVAL)
|
|
8670
|
-
except Exception as e:
|
|
8671
|
-
log_error(f"Metrics collector error: {e}")
|
|
8672
|
-
|
|
8673
|
-
log_info("Metrics collector stopped")
|
|
8674
|
-
|
|
8675
|
-
|
|
8676
|
-
def start_metrics_collector(shutdown_event: threading.Event) -> threading.Thread:
|
|
8677
|
-
"""
|
|
8678
|
-
Start the metrics collector as a daemon thread.
|
|
8679
|
-
|
|
8680
|
-
Args:
|
|
8681
|
-
shutdown_event: Threading event to signal shutdown
|
|
8682
|
-
|
|
8683
|
-
Returns:
|
|
8684
|
-
The started thread (for joining if needed)
|
|
8685
|
-
"""
|
|
8686
|
-
thread = threading.Thread(
|
|
8687
|
-
target=metrics_collector_loop,
|
|
8688
|
-
args=(shutdown_event,),
|
|
8689
|
-
daemon=True,
|
|
8690
|
-
name="metrics-collector"
|
|
8691
|
-
)
|
|
8692
|
-
thread.start()
|
|
8693
|
-
return thread
|
|
8694
|
-
`;
|
|
8695
|
-
|
|
8696
|
-
// ../../packages/core/src/sandbox/scripts/lib/upload_telemetry.py.ts
|
|
8697
|
-
var UPLOAD_TELEMETRY_SCRIPT = `#!/usr/bin/env python3
|
|
8698
|
-
"""
|
|
8699
|
-
Telemetry upload module for VM0 sandbox.
|
|
8700
|
-
Reads system log and metrics files, tracks position to avoid duplicates,
|
|
8701
|
-
and uploads to the telemetry webhook endpoint.
|
|
8702
|
-
Masks secrets before sending using client-side masking.
|
|
8703
|
-
"""
|
|
8704
|
-
import json
|
|
8705
|
-
import os
|
|
8706
|
-
import threading
|
|
8707
|
-
from typing import List, Dict, Any
|
|
8708
|
-
|
|
8709
|
-
from common import (
|
|
8710
|
-
RUN_ID, TELEMETRY_URL, TELEMETRY_INTERVAL,
|
|
8711
|
-
SYSTEM_LOG_FILE, METRICS_LOG_FILE, NETWORK_LOG_FILE, SANDBOX_OPS_LOG_FILE,
|
|
8712
|
-
TELEMETRY_LOG_POS_FILE, TELEMETRY_METRICS_POS_FILE, TELEMETRY_NETWORK_POS_FILE,
|
|
8713
|
-
TELEMETRY_SANDBOX_OPS_POS_FILE
|
|
8714
|
-
)
|
|
8715
|
-
from log import log_info, log_error, log_debug, log_warn
|
|
8716
|
-
from http_client import http_post_json
|
|
8717
|
-
from secret_masker import mask_data
|
|
8718
|
-
|
|
8719
|
-
|
|
8720
|
-
def read_file_from_position(file_path: str, pos_file: str) -> tuple[str, int]:
|
|
8721
|
-
"""
|
|
8722
|
-
Read new content from file starting from last position.
|
|
8723
|
-
|
|
8724
|
-
Args:
|
|
8725
|
-
file_path: Path to the file to read
|
|
8726
|
-
pos_file: Path to position tracking file
|
|
8727
|
-
|
|
8728
|
-
Returns:
|
|
8729
|
-
Tuple of (new_content, new_position)
|
|
8730
|
-
"""
|
|
8731
|
-
# Get last read position
|
|
8732
|
-
last_pos = 0
|
|
8733
|
-
if os.path.exists(pos_file):
|
|
8734
|
-
try:
|
|
8735
|
-
with open(pos_file, "r") as f:
|
|
8736
|
-
last_pos = int(f.read().strip())
|
|
8737
|
-
except (ValueError, IOError):
|
|
8738
|
-
last_pos = 0
|
|
8739
|
-
|
|
8740
|
-
# Read new content
|
|
8741
|
-
new_content = ""
|
|
8742
|
-
new_pos = last_pos
|
|
8743
|
-
|
|
8744
|
-
if os.path.exists(file_path):
|
|
8745
|
-
try:
|
|
8746
|
-
with open(file_path, "r") as f:
|
|
8747
|
-
f.seek(last_pos)
|
|
8748
|
-
new_content = f.read()
|
|
8749
|
-
new_pos = f.tell()
|
|
8750
|
-
except IOError as e:
|
|
8751
|
-
log_debug(f"Failed to read {file_path}: {e}")
|
|
8752
|
-
|
|
8753
|
-
return new_content, new_pos
|
|
8754
|
-
|
|
8755
|
-
|
|
8756
|
-
def save_position(pos_file: str, position: int) -> None:
|
|
8757
|
-
"""Save file read position for next iteration."""
|
|
8758
|
-
try:
|
|
8759
|
-
with open(pos_file, "w") as f:
|
|
8760
|
-
f.write(str(position))
|
|
8761
|
-
except IOError as e:
|
|
8762
|
-
log_debug(f"Failed to save position to {pos_file}: {e}")
|
|
8763
|
-
|
|
8764
|
-
|
|
8765
|
-
def read_jsonl_from_position(file_path: str, pos_file: str) -> tuple[List[Dict[str, Any]], int]:
|
|
8766
|
-
"""
|
|
8767
|
-
Read new entries from JSONL file starting from last position.
|
|
8768
|
-
|
|
8769
|
-
Args:
|
|
8770
|
-
file_path: Path to the JSONL file to read
|
|
8771
|
-
pos_file: Path to position tracking file
|
|
8772
|
-
|
|
8773
|
-
Returns:
|
|
8774
|
-
Tuple of (entries list, new_position)
|
|
8775
|
-
"""
|
|
8776
|
-
content, new_pos = read_file_from_position(file_path, pos_file)
|
|
8777
|
-
|
|
8778
|
-
entries = []
|
|
8779
|
-
if content:
|
|
8780
|
-
for line in content.strip().split("\\n"):
|
|
8781
|
-
if line:
|
|
8782
|
-
try:
|
|
8783
|
-
entries.append(json.loads(line))
|
|
8784
|
-
except json.JSONDecodeError:
|
|
8785
|
-
pass
|
|
8786
|
-
|
|
8787
|
-
return entries, new_pos
|
|
8788
|
-
|
|
8789
|
-
|
|
8790
|
-
def read_metrics_from_position(pos_file: str) -> tuple[List[Dict[str, Any]], int]:
|
|
8791
|
-
"""
|
|
8792
|
-
Read new metrics from JSONL file starting from last position.
|
|
8793
|
-
|
|
8794
|
-
Args:
|
|
8795
|
-
pos_file: Path to position tracking file
|
|
8796
|
-
|
|
8797
|
-
Returns:
|
|
8798
|
-
Tuple of (metrics list, new_position)
|
|
8799
|
-
"""
|
|
8800
|
-
return read_jsonl_from_position(METRICS_LOG_FILE, pos_file)
|
|
8801
|
-
|
|
8802
|
-
|
|
8803
|
-
def read_network_logs_from_position(pos_file: str) -> tuple[List[Dict[str, Any]], int]:
|
|
8804
|
-
"""
|
|
8805
|
-
Read new network logs from JSONL file starting from last position.
|
|
8806
|
-
|
|
8807
|
-
Args:
|
|
8808
|
-
pos_file: Path to position tracking file
|
|
8809
|
-
|
|
8810
|
-
Returns:
|
|
8811
|
-
Tuple of (network logs list, new_position)
|
|
8812
|
-
"""
|
|
8813
|
-
return read_jsonl_from_position(NETWORK_LOG_FILE, pos_file)
|
|
8814
|
-
|
|
8815
|
-
|
|
8816
|
-
def read_sandbox_ops_from_position(pos_file: str) -> tuple[List[Dict[str, Any]], int]:
|
|
8817
|
-
"""
|
|
8818
|
-
Read new sandbox operations from JSONL file starting from last position.
|
|
8819
|
-
|
|
8820
|
-
Args:
|
|
8821
|
-
pos_file: Path to position tracking file
|
|
8822
|
-
|
|
8823
|
-
Returns:
|
|
8824
|
-
Tuple of (sandbox operations list, new_position)
|
|
8825
|
-
"""
|
|
8826
|
-
return read_jsonl_from_position(SANDBOX_OPS_LOG_FILE, pos_file)
|
|
8827
|
-
|
|
8828
|
-
|
|
8829
|
-
def upload_telemetry() -> bool:
|
|
8830
|
-
"""
|
|
8831
|
-
Upload telemetry data to VM0 API.
|
|
8832
|
-
|
|
8833
|
-
Returns:
|
|
8834
|
-
True if upload succeeded or no data to upload, False on failure
|
|
8835
|
-
"""
|
|
8836
|
-
# Read new system log content
|
|
8837
|
-
system_log, log_pos = read_file_from_position(SYSTEM_LOG_FILE, TELEMETRY_LOG_POS_FILE)
|
|
8838
|
-
|
|
8839
|
-
# Read new metrics
|
|
8840
|
-
metrics, metrics_pos = read_metrics_from_position(TELEMETRY_METRICS_POS_FILE)
|
|
8841
|
-
|
|
8842
|
-
# Read new network logs
|
|
8843
|
-
network_logs, network_pos = read_network_logs_from_position(TELEMETRY_NETWORK_POS_FILE)
|
|
8844
|
-
|
|
8845
|
-
# Read new sandbox operations
|
|
8846
|
-
sandbox_ops, sandbox_ops_pos = read_sandbox_ops_from_position(TELEMETRY_SANDBOX_OPS_POS_FILE)
|
|
8847
|
-
|
|
8848
|
-
# Skip if nothing new
|
|
8849
|
-
if not system_log and not metrics and not network_logs and not sandbox_ops:
|
|
8850
|
-
log_debug("No new telemetry data to upload")
|
|
8851
|
-
return True
|
|
8852
|
-
|
|
8853
|
-
# Mask secrets in telemetry data before sending
|
|
8854
|
-
# System log and network logs may contain sensitive information
|
|
8855
|
-
masked_system_log = mask_data(system_log) if system_log else ""
|
|
8856
|
-
masked_network_logs = mask_data(network_logs) if network_logs else []
|
|
8857
|
-
|
|
8858
|
-
# Upload to API
|
|
8859
|
-
payload = {
|
|
8860
|
-
"runId": RUN_ID,
|
|
8861
|
-
"systemLog": masked_system_log,
|
|
8862
|
-
"metrics": metrics, # Metrics don't contain secrets (just numbers)
|
|
8863
|
-
"networkLogs": masked_network_logs,
|
|
8864
|
-
"sandboxOperations": sandbox_ops # Sandbox ops don't contain secrets (just timing data)
|
|
8865
|
-
}
|
|
8866
|
-
|
|
8867
|
-
log_debug(f"Uploading telemetry: {len(system_log)} bytes log, {len(metrics)} metrics, {len(network_logs)} network logs, {len(sandbox_ops)} sandbox ops")
|
|
8868
|
-
|
|
8869
|
-
result = http_post_json(TELEMETRY_URL, payload, max_retries=1)
|
|
8870
|
-
|
|
8871
|
-
if result:
|
|
8872
|
-
# Save positions only on successful upload
|
|
8873
|
-
save_position(TELEMETRY_LOG_POS_FILE, log_pos)
|
|
8874
|
-
save_position(TELEMETRY_METRICS_POS_FILE, metrics_pos)
|
|
8875
|
-
save_position(TELEMETRY_NETWORK_POS_FILE, network_pos)
|
|
8876
|
-
save_position(TELEMETRY_SANDBOX_OPS_POS_FILE, sandbox_ops_pos)
|
|
8877
|
-
log_debug(f"Telemetry uploaded successfully: {result.get('id', 'unknown')}")
|
|
8878
|
-
return True
|
|
8879
|
-
else:
|
|
8880
|
-
log_warn("Failed to upload telemetry (will retry next interval)")
|
|
8881
|
-
return False
|
|
8882
|
-
|
|
8883
|
-
|
|
8884
|
-
def telemetry_upload_loop(shutdown_event: threading.Event) -> None:
|
|
8885
|
-
"""
|
|
8886
|
-
Background loop that uploads telemetry every TELEMETRY_INTERVAL seconds.
|
|
8887
|
-
"""
|
|
8888
|
-
log_info(f"Telemetry upload started (interval: {TELEMETRY_INTERVAL}s)")
|
|
8889
|
-
|
|
8890
|
-
while not shutdown_event.is_set():
|
|
8891
|
-
try:
|
|
8892
|
-
upload_telemetry()
|
|
8893
|
-
except Exception as e:
|
|
8894
|
-
log_error(f"Telemetry upload error: {e}")
|
|
8895
|
-
|
|
8896
|
-
# Wait for interval or shutdown
|
|
8897
|
-
shutdown_event.wait(TELEMETRY_INTERVAL)
|
|
8898
|
-
|
|
8899
|
-
log_info("Telemetry upload stopped")
|
|
8900
|
-
|
|
8901
|
-
|
|
8902
|
-
def start_telemetry_upload(shutdown_event: threading.Event) -> threading.Thread:
|
|
8903
|
-
"""
|
|
8904
|
-
Start the telemetry uploader as a daemon thread.
|
|
8905
|
-
|
|
8906
|
-
Args:
|
|
8907
|
-
shutdown_event: Threading event to signal shutdown
|
|
8908
|
-
|
|
8909
|
-
Returns:
|
|
8910
|
-
The started thread (for joining if needed)
|
|
8911
|
-
"""
|
|
8912
|
-
thread = threading.Thread(
|
|
8913
|
-
target=telemetry_upload_loop,
|
|
8914
|
-
args=(shutdown_event,),
|
|
8915
|
-
daemon=True,
|
|
8916
|
-
name="telemetry-upload"
|
|
8917
|
-
)
|
|
8918
|
-
thread.start()
|
|
8919
|
-
return thread
|
|
8920
|
-
|
|
8921
|
-
|
|
8922
|
-
def final_telemetry_upload() -> bool:
|
|
8923
|
-
"""
|
|
8924
|
-
Perform final telemetry upload before agent completion.
|
|
8925
|
-
This ensures all remaining data is captured.
|
|
8926
|
-
|
|
8927
|
-
Returns:
|
|
8928
|
-
True if upload succeeded, False on failure
|
|
8929
|
-
"""
|
|
8930
|
-
log_info("Performing final telemetry upload...")
|
|
8931
|
-
return upload_telemetry()
|
|
8932
|
-
`;
|
|
8933
|
-
|
|
8934
|
-
// ../../packages/core/src/sandbox/scripts/lib/secret_masker.py.ts
|
|
8935
|
-
var SECRET_MASKER_SCRIPT = `#!/usr/bin/env python3
|
|
8936
|
-
"""
|
|
8937
|
-
Secret masking module for VM0 sandbox.
|
|
8938
|
-
|
|
8939
|
-
Masks secrets in event data before sending to server.
|
|
8940
|
-
Similar to GitHub Actions secret masking.
|
|
8941
|
-
"""
|
|
8942
|
-
import os
|
|
8943
|
-
import base64
|
|
8944
|
-
from urllib.parse import quote as url_encode
|
|
8945
|
-
from typing import Any, Set, List, Optional
|
|
8946
|
-
|
|
8947
|
-
# Placeholder for masked secrets
|
|
8948
|
-
MASK_PLACEHOLDER = "***"
|
|
8949
|
-
|
|
8950
|
-
# Minimum length for secrets (avoid false positives on short strings)
|
|
8951
|
-
MIN_SECRET_LENGTH = 5
|
|
8952
|
-
|
|
8953
|
-
# Global masker instance (initialized lazily)
|
|
8954
|
-
_masker: Optional["SecretMasker"] = None
|
|
8955
|
-
|
|
8956
|
-
|
|
8957
|
-
class SecretMasker:
|
|
8958
|
-
"""
|
|
8959
|
-
Masks secret values in data structures.
|
|
8960
|
-
Pre-computes encoding variants for efficient matching.
|
|
8961
|
-
"""
|
|
8962
|
-
|
|
8963
|
-
def __init__(self, secret_values: List[str]):
|
|
8964
|
-
"""
|
|
8965
|
-
Initialize masker with secret values.
|
|
8966
|
-
|
|
8967
|
-
Args:
|
|
8968
|
-
secret_values: List of secret values to mask
|
|
8969
|
-
"""
|
|
8970
|
-
self.patterns: Set[str] = set()
|
|
8971
|
-
|
|
8972
|
-
for secret in secret_values:
|
|
8973
|
-
if not secret or len(secret) < MIN_SECRET_LENGTH:
|
|
8974
|
-
continue
|
|
8975
|
-
|
|
8976
|
-
# Original value
|
|
8977
|
-
self.patterns.add(secret)
|
|
8978
|
-
|
|
8979
|
-
# Base64 encoded
|
|
8980
|
-
try:
|
|
8981
|
-
b64 = base64.b64encode(secret.encode()).decode()
|
|
8982
|
-
if len(b64) >= MIN_SECRET_LENGTH:
|
|
8983
|
-
self.patterns.add(b64)
|
|
8984
|
-
except Exception:
|
|
8985
|
-
pass
|
|
8986
|
-
|
|
8987
|
-
# URL encoded (only if different from original)
|
|
8988
|
-
try:
|
|
8989
|
-
url_enc = url_encode(secret, safe="")
|
|
8990
|
-
if url_enc != secret and len(url_enc) >= MIN_SECRET_LENGTH:
|
|
8991
|
-
self.patterns.add(url_enc)
|
|
8992
|
-
except Exception:
|
|
8993
|
-
pass
|
|
8994
|
-
|
|
8995
|
-
def mask(self, data: Any) -> Any:
|
|
8996
|
-
"""
|
|
8997
|
-
Recursively mask all occurrences of secrets in the data.
|
|
8998
|
-
|
|
8999
|
-
Args:
|
|
9000
|
-
data: Data to mask (string, list, dict, or primitive)
|
|
9001
|
-
|
|
9002
|
-
Returns:
|
|
9003
|
-
Masked data with the same structure
|
|
9004
|
-
"""
|
|
9005
|
-
return self._deep_mask(data)
|
|
9006
|
-
|
|
9007
|
-
def _deep_mask(self, data: Any) -> Any:
|
|
9008
|
-
"""Recursively mask data."""
|
|
9009
|
-
if isinstance(data, str):
|
|
9010
|
-
result = data
|
|
9011
|
-
for pattern in self.patterns:
|
|
9012
|
-
# Use split/join for global replacement
|
|
9013
|
-
result = result.replace(pattern, MASK_PLACEHOLDER)
|
|
9014
|
-
return result
|
|
9015
|
-
|
|
9016
|
-
if isinstance(data, list):
|
|
9017
|
-
return [self._deep_mask(item) for item in data]
|
|
9018
|
-
|
|
9019
|
-
if isinstance(data, dict):
|
|
9020
|
-
return {key: self._deep_mask(value) for key, value in data.items()}
|
|
9021
|
-
|
|
9022
|
-
# Primitives (int, float, bool, None) pass through unchanged
|
|
9023
|
-
return data
|
|
9024
|
-
|
|
9025
|
-
|
|
9026
|
-
def get_masker() -> SecretMasker:
|
|
9027
|
-
"""
|
|
9028
|
-
Get the global masker instance.
|
|
9029
|
-
Initializes on first call using VM0_SECRET_VALUES env var.
|
|
9030
|
-
|
|
9031
|
-
Returns:
|
|
9032
|
-
SecretMasker instance
|
|
9033
|
-
"""
|
|
9034
|
-
global _masker
|
|
9035
|
-
|
|
9036
|
-
if _masker is None:
|
|
9037
|
-
_masker = _create_masker()
|
|
9038
|
-
|
|
9039
|
-
return _masker
|
|
9040
|
-
|
|
9041
|
-
|
|
9042
|
-
def _create_masker() -> SecretMasker:
|
|
9043
|
-
"""
|
|
9044
|
-
Create a masker from VM0_SECRET_VALUES env var.
|
|
9045
|
-
|
|
9046
|
-
VM0_SECRET_VALUES contains comma-separated base64-encoded secret values.
|
|
9047
|
-
This avoids exposing plaintext secrets in environment variable names.
|
|
9048
|
-
"""
|
|
9049
|
-
secret_values_str = os.environ.get("VM0_SECRET_VALUES", "")
|
|
9050
|
-
|
|
9051
|
-
if not secret_values_str:
|
|
9052
|
-
# No secrets to mask
|
|
9053
|
-
return SecretMasker([])
|
|
9054
|
-
|
|
9055
|
-
# Parse base64-encoded values
|
|
9056
|
-
secret_values = []
|
|
9057
|
-
for encoded_value in secret_values_str.split(","):
|
|
9058
|
-
encoded_value = encoded_value.strip()
|
|
9059
|
-
if encoded_value:
|
|
9060
|
-
try:
|
|
9061
|
-
decoded = base64.b64decode(encoded_value).decode()
|
|
9062
|
-
if decoded:
|
|
9063
|
-
secret_values.append(decoded)
|
|
9064
|
-
except Exception:
|
|
9065
|
-
# Skip invalid base64 values
|
|
9066
|
-
pass
|
|
9067
|
-
|
|
9068
|
-
return SecretMasker(secret_values)
|
|
9069
|
-
|
|
9070
|
-
|
|
9071
|
-
def mask_data(data: Any) -> Any:
|
|
9072
|
-
"""
|
|
9073
|
-
Convenience function to mask data using global masker.
|
|
9074
|
-
|
|
9075
|
-
Args:
|
|
9076
|
-
data: Data to mask
|
|
9077
|
-
|
|
9078
|
-
Returns:
|
|
9079
|
-
Masked data
|
|
9080
|
-
"""
|
|
9081
|
-
return get_masker().mask(data)
|
|
9082
|
-
`;
|
|
9083
|
-
|
|
9084
|
-
// ../../packages/core/src/sandbox/scripts/run-agent.py.ts
|
|
9085
|
-
var RUN_AGENT_SCRIPT = `#!/usr/bin/env python3
|
|
9086
|
-
"""
|
|
9087
|
-
Main agent execution orchestrator for VM0.
|
|
9088
|
-
This script coordinates the execution of Claude Code and handles:
|
|
9089
|
-
- Working directory setup
|
|
9090
|
-
- Claude CLI execution with JSONL streaming
|
|
9091
|
-
- Event sending to webhook
|
|
9092
|
-
- Checkpoint creation on success
|
|
9093
|
-
- Complete API call on finish
|
|
9094
|
-
|
|
9095
|
-
Design principles:
|
|
9096
|
-
- Never call sys.exit() in the middle of execution - use raise instead
|
|
9097
|
-
- Single exit point at the very end of if __name__ == "__main__"
|
|
9098
|
-
- finally block guarantees cleanup runs regardless of success/failure
|
|
9099
|
-
- Complete API passes error message for CLI to display
|
|
9100
|
-
"""
|
|
9101
|
-
import os
|
|
9102
|
-
import sys
|
|
9103
|
-
import subprocess
|
|
9104
|
-
import json
|
|
9105
|
-
import threading
|
|
9106
|
-
import time
|
|
9107
|
-
|
|
9108
|
-
# Add lib to path for imports
|
|
9109
|
-
sys.path.insert(0, "/usr/local/bin/vm0-agent/lib")
|
|
9110
|
-
|
|
9111
|
-
from common import (
|
|
9112
|
-
WORKING_DIR, PROMPT, RESUME_SESSION_ID, COMPLETE_URL, RUN_ID,
|
|
9113
|
-
EVENT_ERROR_FLAG, HEARTBEAT_URL, HEARTBEAT_INTERVAL, AGENT_LOG_FILE,
|
|
9114
|
-
CLI_AGENT_TYPE, OPENAI_MODEL, validate_config, record_sandbox_op
|
|
9115
|
-
)
|
|
9116
|
-
from log import log_info, log_error, log_warn
|
|
9117
|
-
from events import send_event
|
|
9118
|
-
from checkpoint import create_checkpoint
|
|
9119
|
-
from http_client import http_post_json
|
|
9120
|
-
from metrics import start_metrics_collector
|
|
9121
|
-
from upload_telemetry import start_telemetry_upload, final_telemetry_upload
|
|
9122
|
-
|
|
9123
|
-
# Global shutdown event for heartbeat thread
|
|
9124
|
-
shutdown_event = threading.Event()
|
|
9125
|
-
|
|
9126
|
-
|
|
9127
|
-
def heartbeat_loop():
|
|
9128
|
-
"""Send periodic heartbeat signals to indicate agent is still alive."""
|
|
9129
|
-
while not shutdown_event.is_set():
|
|
9130
|
-
try:
|
|
9131
|
-
if http_post_json(HEARTBEAT_URL, {"runId": RUN_ID}):
|
|
9132
|
-
log_info("Heartbeat sent")
|
|
9133
|
-
else:
|
|
9134
|
-
log_warn("Heartbeat failed")
|
|
9135
|
-
except Exception as e:
|
|
9136
|
-
log_warn(f"Heartbeat error: {e}")
|
|
9137
|
-
# Wait for interval or until shutdown
|
|
9138
|
-
shutdown_event.wait(HEARTBEAT_INTERVAL)
|
|
9139
|
-
|
|
9140
|
-
|
|
9141
|
-
def _cleanup(exit_code: int, error_message: str):
|
|
9142
|
-
"""
|
|
9143
|
-
Cleanup and notify server.
|
|
9144
|
-
This function is called in the finally block to ensure it always runs.
|
|
9145
|
-
"""
|
|
9146
|
-
log_info("\u25B7 Cleanup")
|
|
9147
|
-
|
|
9148
|
-
# Perform final telemetry upload before completion
|
|
9149
|
-
# This ensures all remaining data is captured
|
|
9150
|
-
telemetry_start = time.time()
|
|
9151
|
-
telemetry_success = True
|
|
9152
|
-
try:
|
|
9153
|
-
final_telemetry_upload()
|
|
9154
|
-
except Exception as e:
|
|
9155
|
-
telemetry_success = False
|
|
9156
|
-
log_error(f"Final telemetry upload failed: {e}")
|
|
9157
|
-
record_sandbox_op("final_telemetry_upload", int((time.time() - telemetry_start) * 1000), telemetry_success)
|
|
9158
|
-
|
|
9159
|
-
# Always call complete API at the end
|
|
9160
|
-
# This sends vm0_result (on success) or vm0_error (on failure) and kills the sandbox
|
|
9161
|
-
log_info(f"Calling complete API with exitCode={exit_code}")
|
|
9162
|
-
|
|
9163
|
-
complete_payload = {
|
|
9164
|
-
"runId": RUN_ID,
|
|
9165
|
-
"exitCode": exit_code
|
|
9166
|
-
}
|
|
9167
|
-
if error_message:
|
|
9168
|
-
complete_payload["error"] = error_message
|
|
9169
|
-
|
|
9170
|
-
complete_start = time.time()
|
|
9171
|
-
complete_success = False
|
|
9172
|
-
try:
|
|
9173
|
-
if http_post_json(COMPLETE_URL, complete_payload):
|
|
9174
|
-
log_info("Complete API called successfully")
|
|
9175
|
-
complete_success = True
|
|
9176
|
-
else:
|
|
9177
|
-
log_error("Failed to call complete API (sandbox may not be cleaned up)")
|
|
9178
|
-
except Exception as e:
|
|
9179
|
-
log_error(f"Complete API call failed: {e}")
|
|
9180
|
-
record_sandbox_op("complete_api_call", int((time.time() - complete_start) * 1000), complete_success)
|
|
9181
|
-
|
|
9182
|
-
# Stop heartbeat thread
|
|
9183
|
-
shutdown_event.set()
|
|
9184
|
-
log_info("Heartbeat thread stopped")
|
|
9185
|
-
|
|
9186
|
-
# Log final status
|
|
9187
|
-
if exit_code == 0:
|
|
9188
|
-
log_info("\u2713 Sandbox finished successfully")
|
|
9189
|
-
else:
|
|
9190
|
-
log_info(f"\u2717 Sandbox failed (exit code {exit_code})")
|
|
9191
|
-
|
|
9192
|
-
|
|
9193
|
-
def _run() -> tuple[int, str]:
|
|
9194
|
-
"""
|
|
9195
|
-
Main execution logic.
|
|
9196
|
-
Raises exceptions on failure instead of calling sys.exit().
|
|
9197
|
-
Returns (exit_code, error_message) tuple on completion.
|
|
9198
|
-
"""
|
|
9199
|
-
# Validate configuration - raises ValueError if invalid
|
|
9200
|
-
validate_config()
|
|
9201
|
-
|
|
9202
|
-
# Lifecycle: Header
|
|
9203
|
-
log_info(f"\u25B6 VM0 Sandbox {RUN_ID}")
|
|
9204
|
-
|
|
9205
|
-
# Lifecycle: Initialization
|
|
9206
|
-
log_info("\u25B7 Initialization")
|
|
9207
|
-
init_start_time = time.time()
|
|
9208
|
-
|
|
9209
|
-
log_info(f"Working directory: {WORKING_DIR}")
|
|
9210
|
-
|
|
9211
|
-
# Start heartbeat thread
|
|
9212
|
-
heartbeat_start = time.time()
|
|
9213
|
-
heartbeat_thread = threading.Thread(target=heartbeat_loop, daemon=True)
|
|
9214
|
-
heartbeat_thread.start()
|
|
9215
|
-
log_info("Heartbeat thread started")
|
|
9216
|
-
record_sandbox_op("heartbeat_start", int((time.time() - heartbeat_start) * 1000), True)
|
|
9217
|
-
|
|
9218
|
-
# Start metrics collector thread
|
|
9219
|
-
metrics_start = time.time()
|
|
9220
|
-
start_metrics_collector(shutdown_event)
|
|
9221
|
-
log_info("Metrics collector thread started")
|
|
9222
|
-
record_sandbox_op("metrics_collector_start", int((time.time() - metrics_start) * 1000), True)
|
|
9223
|
-
|
|
9224
|
-
# Start telemetry upload thread
|
|
9225
|
-
telemetry_start = time.time()
|
|
9226
|
-
start_telemetry_upload(shutdown_event)
|
|
9227
|
-
log_info("Telemetry upload thread started")
|
|
9228
|
-
record_sandbox_op("telemetry_upload_start", int((time.time() - telemetry_start) * 1000), True)
|
|
9229
|
-
|
|
9230
|
-
# Create and change to working directory - raises RuntimeError if fails
|
|
9231
|
-
# Directory may not exist if no artifact/storage was downloaded (e.g., first run)
|
|
9232
|
-
working_dir_start = time.time()
|
|
9233
|
-
working_dir_success = True
|
|
9234
|
-
try:
|
|
9235
|
-
os.makedirs(WORKING_DIR, exist_ok=True)
|
|
9236
|
-
os.chdir(WORKING_DIR)
|
|
9237
|
-
except OSError as e:
|
|
9238
|
-
working_dir_success = False
|
|
9239
|
-
record_sandbox_op("working_dir_setup", int((time.time() - working_dir_start) * 1000), False, str(e))
|
|
9240
|
-
raise RuntimeError(f"Failed to create/change to working directory: {WORKING_DIR} - {e}") from e
|
|
9241
|
-
record_sandbox_op("working_dir_setup", int((time.time() - working_dir_start) * 1000), working_dir_success)
|
|
9242
|
-
|
|
9243
|
-
# Set up Codex configuration if using Codex CLI
|
|
9244
|
-
# Claude Code uses ~/.claude by default (no configuration needed)
|
|
9245
|
-
if CLI_AGENT_TYPE == "codex":
|
|
9246
|
-
home_dir = os.environ.get("HOME", "/home/user")
|
|
9247
|
-
# Codex uses ~/.codex for configuration and session storage
|
|
9248
|
-
codex_home = f"{home_dir}/.codex"
|
|
9249
|
-
os.makedirs(codex_home, exist_ok=True)
|
|
9250
|
-
os.environ["CODEX_HOME"] = codex_home
|
|
9251
|
-
log_info(f"Codex home directory: {codex_home}")
|
|
9252
|
-
|
|
9253
|
-
# Login with API key via stdin (recommended method)
|
|
9254
|
-
codex_login_start = time.time()
|
|
9255
|
-
codex_login_success = False
|
|
9256
|
-
api_key = os.environ.get("OPENAI_API_KEY", "")
|
|
9257
|
-
if api_key:
|
|
9258
|
-
result = subprocess.run(
|
|
9259
|
-
["codex", "login", "--with-api-key"],
|
|
9260
|
-
input=api_key,
|
|
9261
|
-
text=True,
|
|
9262
|
-
capture_output=True
|
|
9263
|
-
)
|
|
9264
|
-
if result.returncode == 0:
|
|
9265
|
-
log_info("Codex authenticated with API key")
|
|
9266
|
-
codex_login_success = True
|
|
9267
|
-
else:
|
|
9268
|
-
log_error(f"Codex login failed: {result.stderr}")
|
|
9269
|
-
else:
|
|
9270
|
-
log_error("OPENAI_API_KEY not set")
|
|
9271
|
-
record_sandbox_op("codex_login", int((time.time() - codex_login_start) * 1000), codex_login_success)
|
|
9272
|
-
|
|
9273
|
-
init_duration_ms = int((time.time() - init_start_time) * 1000)
|
|
9274
|
-
record_sandbox_op("init_total", init_duration_ms, True)
|
|
9275
|
-
log_info(f"\u2713 Initialization complete ({init_duration_ms // 1000}s)")
|
|
9276
|
-
|
|
9277
|
-
# Lifecycle: Execution
|
|
9278
|
-
log_info("\u25B7 Execution")
|
|
9279
|
-
exec_start_time = time.time()
|
|
9280
|
-
|
|
9281
|
-
# Execute CLI agent with JSONL output
|
|
9282
|
-
log_info(f"Starting {CLI_AGENT_TYPE} execution...")
|
|
9283
|
-
log_info(f"Prompt: {PROMPT}")
|
|
9284
|
-
|
|
9285
|
-
# Build command based on CLI agent type
|
|
9286
|
-
use_mock = os.environ.get("USE_MOCK_CLAUDE") == "true"
|
|
9287
|
-
|
|
9288
|
-
if CLI_AGENT_TYPE == "codex":
|
|
9289
|
-
# Build Codex command
|
|
9290
|
-
if use_mock:
|
|
9291
|
-
# Mock mode not yet supported for Codex
|
|
9292
|
-
raise RuntimeError("Mock mode not supported for Codex")
|
|
9293
|
-
|
|
9294
|
-
if RESUME_SESSION_ID:
|
|
9295
|
-
# Codex resume uses subcommand: codex exec resume <session_id> <prompt>
|
|
9296
|
-
log_info(f"Resuming session: {RESUME_SESSION_ID}")
|
|
9297
|
-
codex_args = [
|
|
9298
|
-
"exec",
|
|
9299
|
-
"--json",
|
|
9300
|
-
"--dangerously-bypass-approvals-and-sandbox",
|
|
9301
|
-
"--skip-git-repo-check",
|
|
9302
|
-
"-C", WORKING_DIR,
|
|
9303
|
-
]
|
|
9304
|
-
if OPENAI_MODEL:
|
|
9305
|
-
codex_args.extend(["-m", OPENAI_MODEL])
|
|
9306
|
-
codex_args.extend(["resume", RESUME_SESSION_ID, PROMPT])
|
|
9307
|
-
cmd = ["codex"] + codex_args
|
|
9308
|
-
else:
|
|
9309
|
-
log_info("Starting new session")
|
|
9310
|
-
codex_args = [
|
|
9311
|
-
"exec",
|
|
9312
|
-
"--json",
|
|
9313
|
-
"--dangerously-bypass-approvals-and-sandbox",
|
|
9314
|
-
"--skip-git-repo-check",
|
|
9315
|
-
"-C", WORKING_DIR,
|
|
9316
|
-
]
|
|
9317
|
-
if OPENAI_MODEL:
|
|
9318
|
-
log_info(f"Using model: {OPENAI_MODEL}")
|
|
9319
|
-
codex_args.extend(["-m", OPENAI_MODEL])
|
|
9320
|
-
cmd = ["codex"] + codex_args + [PROMPT]
|
|
9321
|
-
else:
|
|
9322
|
-
# Build Claude command - unified for both new and resume sessions
|
|
9323
|
-
claude_args = [
|
|
9324
|
-
"--print", "--verbose",
|
|
9325
|
-
"--output-format", "stream-json",
|
|
9326
|
-
"--dangerously-skip-permissions"
|
|
9327
|
-
]
|
|
9328
|
-
|
|
9329
|
-
if RESUME_SESSION_ID:
|
|
9330
|
-
log_info(f"Resuming session: {RESUME_SESSION_ID}")
|
|
9331
|
-
claude_args.extend(["--resume", RESUME_SESSION_ID])
|
|
9332
|
-
else:
|
|
9333
|
-
log_info("Starting new session")
|
|
9334
|
-
|
|
9335
|
-
# Select Claude binary - use mock-claude for testing if USE_MOCK_CLAUDE is set
|
|
9336
|
-
if use_mock:
|
|
9337
|
-
claude_bin = "/usr/local/bin/vm0-agent/lib/mock_claude.py"
|
|
9338
|
-
log_info("Using mock-claude for testing")
|
|
9339
|
-
else:
|
|
9340
|
-
claude_bin = "claude"
|
|
9341
|
-
|
|
9342
|
-
# Build full command
|
|
9343
|
-
cmd = [claude_bin] + claude_args + [PROMPT]
|
|
9344
|
-
|
|
9345
|
-
# Execute CLI agent and process output stream
|
|
9346
|
-
# Capture both stdout and stderr, write to log file, keep stderr in memory for error extraction
|
|
9347
|
-
agent_exit_code = 0
|
|
9348
|
-
stderr_lines = [] # Keep stderr in memory for error message extraction
|
|
9349
|
-
log_file = None
|
|
9350
|
-
|
|
9351
|
-
try:
|
|
9352
|
-
# Open log file directly in /tmp (no need to create directory)
|
|
9353
|
-
log_file = open(AGENT_LOG_FILE, "w")
|
|
9354
|
-
|
|
9355
|
-
# Python subprocess.PIPE can deadlock if buffer fills up
|
|
9356
|
-
# Use a background thread to drain stderr while we read stdout
|
|
9357
|
-
proc = subprocess.Popen(
|
|
9358
|
-
cmd,
|
|
9359
|
-
stdout=subprocess.PIPE,
|
|
9360
|
-
stderr=subprocess.PIPE,
|
|
9361
|
-
text=True,
|
|
9362
|
-
bufsize=1 # Line buffered for real-time processing
|
|
9363
|
-
)
|
|
9364
|
-
|
|
9365
|
-
# Read stderr in background to prevent buffer deadlock
|
|
9366
|
-
def read_stderr():
|
|
9367
|
-
try:
|
|
9368
|
-
for line in proc.stderr:
|
|
9369
|
-
stderr_lines.append(line)
|
|
9370
|
-
if log_file and not log_file.closed:
|
|
9371
|
-
log_file.write(f"[STDERR] {line}")
|
|
9372
|
-
log_file.flush()
|
|
9373
|
-
except Exception:
|
|
9374
|
-
pass # Ignore errors if file closed
|
|
9375
|
-
|
|
9376
|
-
stderr_thread = threading.Thread(target=read_stderr, daemon=True)
|
|
9377
|
-
stderr_thread.start()
|
|
9378
|
-
|
|
9379
|
-
# Sequence counter for events (1-based)
|
|
9380
|
-
event_sequence = 0
|
|
9381
|
-
|
|
9382
|
-
# Process JSONL output line by line from stdout
|
|
9383
|
-
for line in proc.stdout:
|
|
9384
|
-
# Write raw line to log file
|
|
9385
|
-
if log_file and not log_file.closed:
|
|
9386
|
-
log_file.write(line)
|
|
9387
|
-
log_file.flush()
|
|
9388
|
-
|
|
9389
|
-
stripped = line.strip()
|
|
9390
|
-
|
|
9391
|
-
# Skip empty lines
|
|
9392
|
-
if not stripped:
|
|
9393
|
-
continue
|
|
9394
|
-
|
|
9395
|
-
# Check if line is valid JSON (stdout should only contain JSONL)
|
|
9396
|
-
try:
|
|
9397
|
-
event = json.loads(stripped)
|
|
9398
|
-
|
|
9399
|
-
# Valid JSONL - send immediately with sequence number
|
|
9400
|
-
event_sequence += 1
|
|
9401
|
-
send_event(event, event_sequence)
|
|
9402
|
-
|
|
9403
|
-
# Extract result from "result" event for stdout
|
|
9404
|
-
if event.get("type") == "result":
|
|
9405
|
-
result_content = event.get("result", "")
|
|
9406
|
-
if result_content:
|
|
9407
|
-
print(result_content)
|
|
9408
|
-
|
|
9409
|
-
except json.JSONDecodeError:
|
|
9410
|
-
# Not valid JSON, skip
|
|
9411
|
-
pass
|
|
9412
|
-
|
|
9413
|
-
# Wait for process to complete
|
|
9414
|
-
proc.wait()
|
|
9415
|
-
# Wait for stderr thread to finish (with timeout to avoid hanging)
|
|
9416
|
-
stderr_thread.join(timeout=10)
|
|
9417
|
-
agent_exit_code = proc.returncode
|
|
9418
|
-
|
|
9419
|
-
except Exception as e:
|
|
9420
|
-
log_error(f"Failed to execute {CLI_AGENT_TYPE}: {e}")
|
|
9421
|
-
agent_exit_code = 1
|
|
9422
|
-
finally:
|
|
9423
|
-
if log_file and not log_file.closed:
|
|
9424
|
-
log_file.close()
|
|
9425
|
-
|
|
9426
|
-
# Print newline after output
|
|
9427
|
-
print()
|
|
9428
|
-
|
|
9429
|
-
# Track final exit code for complete API
|
|
9430
|
-
final_exit_code = agent_exit_code
|
|
9431
|
-
error_message = ""
|
|
9432
|
-
|
|
9433
|
-
# Check if any events failed to send
|
|
9434
|
-
if os.path.exists(EVENT_ERROR_FLAG):
|
|
9435
|
-
log_error("Some events failed to send, marking run as failed")
|
|
9436
|
-
final_exit_code = 1
|
|
9437
|
-
error_message = "Some events failed to send"
|
|
9438
|
-
|
|
9439
|
-
# Log execution result and record metric
|
|
9440
|
-
exec_duration_ms = int((time.time() - exec_start_time) * 1000)
|
|
9441
|
-
record_sandbox_op("cli_execution", exec_duration_ms, agent_exit_code == 0)
|
|
9442
|
-
if agent_exit_code == 0 and final_exit_code == 0:
|
|
9443
|
-
log_info(f"\u2713 Execution complete ({exec_duration_ms // 1000}s)")
|
|
9444
|
-
else:
|
|
9445
|
-
log_info(f"\u2717 Execution failed ({exec_duration_ms // 1000}s)")
|
|
9446
|
-
|
|
9447
|
-
# Handle completion
|
|
9448
|
-
if agent_exit_code == 0 and final_exit_code == 0:
|
|
9449
|
-
log_info(f"{CLI_AGENT_TYPE} completed successfully")
|
|
9450
|
-
|
|
9451
|
-
# Lifecycle: Checkpoint
|
|
9452
|
-
log_info("\u25B7 Checkpoint")
|
|
9453
|
-
checkpoint_start_time = time.time()
|
|
9454
|
-
|
|
9455
|
-
# Create checkpoint - this is mandatory for successful runs
|
|
9456
|
-
checkpoint_success = create_checkpoint()
|
|
9457
|
-
checkpoint_duration = int(time.time() - checkpoint_start_time)
|
|
9458
|
-
|
|
9459
|
-
if checkpoint_success:
|
|
9460
|
-
log_info(f"\u2713 Checkpoint complete ({checkpoint_duration}s)")
|
|
9461
|
-
else:
|
|
9462
|
-
log_info(f"\u2717 Checkpoint failed ({checkpoint_duration}s)")
|
|
9463
|
-
|
|
9464
|
-
if not checkpoint_success:
|
|
9465
|
-
log_error("Checkpoint creation failed, marking run as failed")
|
|
9466
|
-
final_exit_code = 1
|
|
9467
|
-
error_message = "Checkpoint creation failed"
|
|
9468
|
-
else:
|
|
9469
|
-
if agent_exit_code != 0:
|
|
9470
|
-
log_info(f"{CLI_AGENT_TYPE} failed with exit code {agent_exit_code}")
|
|
9471
|
-
|
|
9472
|
-
# Get detailed error from captured stderr lines in memory
|
|
9473
|
-
if stderr_lines:
|
|
9474
|
-
error_message = " ".join(line.strip() for line in stderr_lines)
|
|
9475
|
-
log_info(f"Captured stderr: {error_message}")
|
|
9476
|
-
else:
|
|
9477
|
-
error_message = f"Agent exited with code {agent_exit_code}"
|
|
9478
|
-
|
|
9479
|
-
# Note: Keep all temp files for debugging (SESSION_ID_FILE, SESSION_HISTORY_PATH_FILE, EVENT_ERROR_FLAG)
|
|
9480
|
-
|
|
9481
|
-
return final_exit_code, error_message
|
|
9482
|
-
|
|
9483
|
-
|
|
9484
|
-
def main() -> int:
|
|
9485
|
-
"""
|
|
9486
|
-
Main entry point for agent execution.
|
|
9487
|
-
Uses try/except/finally to ensure cleanup always runs.
|
|
9488
|
-
Returns exit code (0 for success, non-zero for failure).
|
|
9489
|
-
"""
|
|
9490
|
-
exit_code = 1 # Default to failure
|
|
9491
|
-
error_message = "Unexpected termination"
|
|
9492
|
-
|
|
9493
|
-
try:
|
|
9494
|
-
exit_code, error_message = _run()
|
|
9495
|
-
|
|
9496
|
-
except ValueError as e:
|
|
9497
|
-
# Configuration validation errors
|
|
9498
|
-
exit_code = 1
|
|
9499
|
-
error_message = str(e)
|
|
9500
|
-
log_error(f"Configuration error: {error_message}")
|
|
9501
|
-
|
|
9502
|
-
except RuntimeError as e:
|
|
9503
|
-
# Runtime errors (e.g., working directory not found)
|
|
9504
|
-
exit_code = 1
|
|
9505
|
-
error_message = str(e)
|
|
9506
|
-
log_error(f"Runtime error: {error_message}")
|
|
9507
|
-
|
|
9508
|
-
except Exception as e:
|
|
9509
|
-
# Catch-all for unexpected exceptions
|
|
9510
|
-
exit_code = 1
|
|
9511
|
-
error_message = f"Unexpected error: {e}"
|
|
9512
|
-
log_error(error_message)
|
|
9513
|
-
|
|
9514
|
-
finally:
|
|
9515
|
-
# Always cleanup and notify server
|
|
9516
|
-
_cleanup(exit_code, error_message)
|
|
9517
|
-
|
|
9518
|
-
return exit_code
|
|
9519
|
-
|
|
9520
|
-
|
|
9521
|
-
if __name__ == "__main__":
|
|
9522
|
-
sys.exit(main())
|
|
9523
|
-
`;
|
|
9524
|
-
|
|
9525
|
-
// ../../packages/core/src/sandbox/scripts/index.ts
|
|
9526
|
-
var SCRIPT_PATHS = {
|
|
9527
|
-
baseDir: "/usr/local/bin/vm0-agent",
|
|
9528
|
-
libDir: "/usr/local/bin/vm0-agent/lib",
|
|
9529
|
-
libInit: "/usr/local/bin/vm0-agent/lib/__init__.py",
|
|
9530
|
-
runAgent: "/usr/local/bin/vm0-agent/run-agent.py",
|
|
9531
|
-
common: "/usr/local/bin/vm0-agent/lib/common.py",
|
|
9532
|
-
log: "/usr/local/bin/vm0-agent/lib/log.py",
|
|
9533
|
-
httpClient: "/usr/local/bin/vm0-agent/lib/http_client.py",
|
|
9534
|
-
events: "/usr/local/bin/vm0-agent/lib/events.py",
|
|
9535
|
-
directUpload: "/usr/local/bin/vm0-agent/lib/direct_upload.py",
|
|
9536
|
-
download: "/usr/local/bin/vm0-agent/lib/download.py",
|
|
9537
|
-
checkpoint: "/usr/local/bin/vm0-agent/lib/checkpoint.py",
|
|
9538
|
-
mockClaude: "/usr/local/bin/vm0-agent/lib/mock_claude.py",
|
|
9539
|
-
metrics: "/usr/local/bin/vm0-agent/lib/metrics.py",
|
|
9540
|
-
uploadTelemetry: "/usr/local/bin/vm0-agent/lib/upload_telemetry.py",
|
|
9541
|
-
secretMasker: "/usr/local/bin/vm0-agent/lib/secret_masker.py"
|
|
9542
|
-
};
|
|
9543
|
-
|
|
9544
|
-
// src/lib/scripts/index.ts
|
|
9545
|
-
var ENV_LOADER_PATH = "/usr/local/bin/vm0-agent/env-loader.py";
|
|
9546
|
-
var ENV_LOADER_SCRIPT = `#!/usr/bin/env python3
|
|
9547
|
-
"""
|
|
9548
|
-
Environment loader wrapper for VM0 runner.
|
|
9549
|
-
Loads environment variables from JSON file before executing run-agent.py.
|
|
9550
|
-
|
|
9551
|
-
This is needed because the runner passes environment variables via SCP (JSON file)
|
|
9552
|
-
rather than directly setting them (which E2B sandbox API supports).
|
|
9553
|
-
"""
|
|
9554
|
-
import os
|
|
9555
|
-
import sys
|
|
9556
|
-
import json
|
|
9557
|
-
|
|
9558
|
-
# Environment JSON file path
|
|
9559
|
-
ENV_JSON_PATH = "/tmp/vm0-env.json"
|
|
9560
|
-
|
|
9561
|
-
print("[env-loader] Starting...", flush=True)
|
|
9562
|
-
|
|
9563
|
-
# Load environment from JSON file
|
|
9564
|
-
if os.path.exists(ENV_JSON_PATH):
|
|
9565
|
-
print(f"[env-loader] Loading environment from {ENV_JSON_PATH}", flush=True)
|
|
9566
|
-
try:
|
|
9567
|
-
with open(ENV_JSON_PATH, "r") as f:
|
|
9568
|
-
env_data = json.load(f)
|
|
9569
|
-
for key, value in env_data.items():
|
|
9570
|
-
os.environ[key] = value
|
|
9571
|
-
print(f"[env-loader] Loaded {len(env_data)} environment variables", flush=True)
|
|
9572
|
-
except Exception as e:
|
|
9573
|
-
print(f"[env-loader] ERROR loading JSON: {e}", flush=True)
|
|
9574
|
-
sys.exit(1)
|
|
9575
|
-
else:
|
|
9576
|
-
print(f"[env-loader] ERROR: Environment file not found: {ENV_JSON_PATH}", flush=True)
|
|
9577
|
-
sys.exit(1)
|
|
9578
|
-
|
|
9579
|
-
# Verify critical environment variables
|
|
9580
|
-
critical_vars = ["VM0_RUN_ID", "VM0_API_URL", "VM0_WORKING_DIR", "VM0_PROMPT"]
|
|
9581
|
-
for var in critical_vars:
|
|
9582
|
-
val = os.environ.get(var, "")
|
|
9583
|
-
if val:
|
|
9584
|
-
print(f"[env-loader] {var}={val[:50]}{'...' if len(val) > 50 else ''}", flush=True)
|
|
9585
|
-
else:
|
|
9586
|
-
print(f"[env-loader] WARNING: {var} is empty", flush=True)
|
|
9587
|
-
|
|
9588
|
-
# Execute run-agent.py in the same process
|
|
9589
|
-
# Using exec to replace this process with run-agent.py
|
|
9590
|
-
run_agent_path = "/usr/local/bin/vm0-agent/run-agent.py"
|
|
9591
|
-
print(f"[env-loader] Executing {run_agent_path}", flush=True)
|
|
9592
|
-
|
|
9593
|
-
# Read and execute the script
|
|
9594
|
-
with open(run_agent_path, "r") as f:
|
|
9595
|
-
code = f.read()
|
|
9596
|
-
|
|
9597
|
-
exec(compile(code, run_agent_path, "exec"))
|
|
9598
|
-
`;
|
|
9599
|
-
|
|
9600
|
-
// src/lib/scripts/utils.ts
|
|
9601
|
-
function getAllScripts() {
|
|
9602
|
-
return [
|
|
9603
|
-
{ content: INIT_SCRIPT, path: SCRIPT_PATHS.libInit },
|
|
9604
|
-
{ content: COMMON_SCRIPT, path: SCRIPT_PATHS.common },
|
|
9605
|
-
{ content: LOG_SCRIPT, path: SCRIPT_PATHS.log },
|
|
9606
|
-
{ content: HTTP_SCRIPT, path: SCRIPT_PATHS.httpClient },
|
|
9607
|
-
{ content: EVENTS_SCRIPT, path: SCRIPT_PATHS.events },
|
|
9608
|
-
{ content: DIRECT_UPLOAD_SCRIPT, path: SCRIPT_PATHS.directUpload },
|
|
9609
|
-
{ content: DOWNLOAD_SCRIPT, path: SCRIPT_PATHS.download },
|
|
9610
|
-
{ content: CHECKPOINT_SCRIPT, path: SCRIPT_PATHS.checkpoint },
|
|
9611
|
-
{ content: MOCK_CLAUDE_SCRIPT, path: SCRIPT_PATHS.mockClaude },
|
|
9612
|
-
{ content: METRICS_SCRIPT, path: SCRIPT_PATHS.metrics },
|
|
9613
|
-
{ content: UPLOAD_TELEMETRY_SCRIPT, path: SCRIPT_PATHS.uploadTelemetry },
|
|
9614
|
-
{ content: SECRET_MASKER_SCRIPT, path: SCRIPT_PATHS.secretMasker },
|
|
9615
|
-
{ content: RUN_AGENT_SCRIPT, path: SCRIPT_PATHS.runAgent },
|
|
9616
|
-
// Env loader is runner-specific (loads env from JSON before executing run-agent.py)
|
|
9617
|
-
{ content: ENV_LOADER_SCRIPT, path: ENV_LOADER_PATH }
|
|
9618
|
-
];
|
|
9619
|
-
}
|
|
9620
|
-
|
|
9621
|
-
// src/lib/proxy/vm-registry.ts
|
|
9622
|
-
import fs4 from "fs";
|
|
9623
|
-
var DEFAULT_REGISTRY_PATH = "/tmp/vm0-vm-registry.json";
|
|
9624
|
-
var VMRegistry = class {
|
|
9625
|
-
registryPath;
|
|
9626
|
-
data;
|
|
9627
|
-
constructor(registryPath = DEFAULT_REGISTRY_PATH) {
|
|
9628
|
-
this.registryPath = registryPath;
|
|
9629
|
-
this.data = this.load();
|
|
9630
|
-
}
|
|
9631
|
-
/**
|
|
9632
|
-
* Load registry data from file
|
|
9633
|
-
*/
|
|
9634
|
-
load() {
|
|
9635
|
-
try {
|
|
9636
|
-
if (fs4.existsSync(this.registryPath)) {
|
|
9637
|
-
const content = fs4.readFileSync(this.registryPath, "utf-8");
|
|
9638
|
-
return JSON.parse(content);
|
|
9639
|
-
}
|
|
9640
|
-
} catch {
|
|
9641
|
-
}
|
|
9642
|
-
return { vms: {}, updatedAt: Date.now() };
|
|
9643
|
-
}
|
|
9644
|
-
/**
|
|
9645
|
-
* Save registry data to file atomically
|
|
9646
|
-
*/
|
|
9647
|
-
save() {
|
|
9648
|
-
this.data.updatedAt = Date.now();
|
|
9649
|
-
const content = JSON.stringify(this.data, null, 2);
|
|
9650
|
-
const tempPath = `${this.registryPath}.tmp`;
|
|
9651
|
-
fs4.writeFileSync(tempPath, content, { mode: 420 });
|
|
9652
|
-
fs4.renameSync(tempPath, this.registryPath);
|
|
9653
|
-
}
|
|
9654
|
-
/**
|
|
9655
|
-
* Register a VM with its IP address
|
|
9656
|
-
*/
|
|
9657
|
-
register(vmIp, runId, sandboxToken, options) {
|
|
9658
|
-
this.data.vms[vmIp] = {
|
|
9659
|
-
runId,
|
|
9660
|
-
sandboxToken,
|
|
9661
|
-
registeredAt: Date.now(),
|
|
9662
|
-
firewallRules: options?.firewallRules,
|
|
9663
|
-
mitmEnabled: options?.mitmEnabled,
|
|
9664
|
-
sealSecretsEnabled: options?.sealSecretsEnabled
|
|
9665
|
-
};
|
|
9666
|
-
this.save();
|
|
9667
|
-
const firewallInfo = options?.firewallRules ? ` with ${options.firewallRules.length} firewall rules` : "";
|
|
9668
|
-
const mitmInfo = options?.mitmEnabled ? ", MITM enabled" : "";
|
|
9669
|
-
console.log(
|
|
9670
|
-
`[VMRegistry] Registered VM ${vmIp} for run ${runId}${firewallInfo}${mitmInfo}`
|
|
9671
|
-
);
|
|
9672
|
-
}
|
|
9673
|
-
/**
|
|
9674
|
-
* Unregister a VM by IP address
|
|
9675
|
-
*/
|
|
9676
|
-
unregister(vmIp) {
|
|
9677
|
-
if (this.data.vms[vmIp]) {
|
|
9678
|
-
const registration = this.data.vms[vmIp];
|
|
9679
|
-
delete this.data.vms[vmIp];
|
|
9680
|
-
this.save();
|
|
9681
|
-
console.log(
|
|
9682
|
-
`[VMRegistry] Unregistered VM ${vmIp} (run ${registration.runId})`
|
|
9683
|
-
);
|
|
9684
|
-
}
|
|
9685
|
-
}
|
|
9686
|
-
/**
|
|
9687
|
-
* Look up registration by VM IP
|
|
9688
|
-
*/
|
|
9689
|
-
lookup(vmIp) {
|
|
9690
|
-
return this.data.vms[vmIp];
|
|
9691
|
-
}
|
|
9692
|
-
/**
|
|
9693
|
-
* Get all registered VMs
|
|
9694
|
-
*/
|
|
9695
|
-
getAll() {
|
|
9696
|
-
return { ...this.data.vms };
|
|
9697
|
-
}
|
|
9698
|
-
/**
|
|
9699
|
-
* Clear all registrations
|
|
9700
|
-
*/
|
|
9701
|
-
clear() {
|
|
9702
|
-
this.data.vms = {};
|
|
9703
|
-
this.save();
|
|
9704
|
-
console.log("[VMRegistry] Cleared all registrations");
|
|
9705
|
-
}
|
|
9706
|
-
/**
|
|
9707
|
-
* Get the path to the registry file
|
|
9708
|
-
*/
|
|
9709
|
-
getRegistryPath() {
|
|
9710
|
-
return this.registryPath;
|
|
9711
|
-
}
|
|
9712
|
-
};
|
|
9713
|
-
var globalRegistry = null;
|
|
9714
|
-
function getVMRegistry() {
|
|
9715
|
-
if (!globalRegistry) {
|
|
9716
|
-
globalRegistry = new VMRegistry();
|
|
9717
|
-
}
|
|
9718
|
-
return globalRegistry;
|
|
9719
|
-
}
|
|
9720
|
-
function initVMRegistry(registryPath) {
|
|
9721
|
-
globalRegistry = new VMRegistry(registryPath);
|
|
9722
|
-
return globalRegistry;
|
|
9723
|
-
}
|
|
9724
|
-
|
|
9725
|
-
// src/lib/proxy/proxy-manager.ts
|
|
9726
|
-
import { spawn as spawn2 } from "child_process";
|
|
9727
|
-
import fs5 from "fs";
|
|
9728
|
-
import path3 from "path";
|
|
9729
|
-
|
|
9730
|
-
// src/lib/proxy/mitm-addon-script.ts
|
|
9731
|
-
var RUNNER_MITM_ADDON_SCRIPT = `#!/usr/bin/env python3
|
|
9732
|
-
"""
|
|
9733
|
-
mitmproxy addon for VM0 runner-level network security mode.
|
|
9734
|
-
|
|
9735
|
-
This addon runs on the runner HOST (not inside VMs) and:
|
|
9736
|
-
1. Intercepts all HTTPS requests from VMs
|
|
9737
|
-
2. Looks up the source VM's runId and firewall rules from the VM registry
|
|
9738
|
-
3. Evaluates firewall rules (first-match-wins) to ALLOW or DENY
|
|
9739
|
-
4. For MITM mode: Rewrites requests to go through VM0 Proxy endpoint
|
|
9740
|
-
5. For SNI-only mode: Passes through or blocks without decryption
|
|
9741
|
-
6. Logs network activity per-run to JSONL files
|
|
9742
|
-
"""
|
|
9743
|
-
import os
|
|
9744
|
-
import json
|
|
9745
|
-
import time
|
|
9746
|
-
import urllib.parse
|
|
9747
|
-
import ipaddress
|
|
9748
|
-
import socket
|
|
9749
|
-
from mitmproxy import http, ctx, tls
|
|
9750
|
-
|
|
9751
|
-
|
|
9752
|
-
# VM0 Proxy configuration from environment
|
|
9753
|
-
API_URL = os.environ.get("VM0_API_URL", "https://www.vm0.ai")
|
|
9754
|
-
REGISTRY_PATH = os.environ.get("VM0_REGISTRY_PATH", "/tmp/vm0-vm-registry.json")
|
|
9755
|
-
VERCEL_BYPASS = os.environ.get("VERCEL_PROTECTION_BYPASS", "")
|
|
9756
|
-
|
|
9757
|
-
# Construct proxy URL
|
|
9758
|
-
PROXY_URL = f"{API_URL}/api/webhooks/agent/proxy"
|
|
9759
|
-
|
|
9760
|
-
# Cache for VM registry (reloaded periodically)
|
|
9761
|
-
_registry_cache = {}
|
|
9762
|
-
_registry_cache_time = 0
|
|
9763
|
-
REGISTRY_CACHE_TTL = 2 # seconds
|
|
9764
|
-
|
|
9765
|
-
# Track request start times for latency calculation
|
|
9766
|
-
request_start_times = {}
|
|
9767
|
-
|
|
9768
|
-
|
|
9769
|
-
def load_registry() -> dict:
|
|
9770
|
-
"""Load the VM registry from file, with caching."""
|
|
9771
|
-
global _registry_cache, _registry_cache_time
|
|
9772
|
-
|
|
9773
|
-
now = time.time()
|
|
9774
|
-
if now - _registry_cache_time < REGISTRY_CACHE_TTL:
|
|
9775
|
-
return _registry_cache
|
|
9776
|
-
|
|
9777
|
-
try:
|
|
9778
|
-
if os.path.exists(REGISTRY_PATH):
|
|
9779
|
-
with open(REGISTRY_PATH, "r") as f:
|
|
9780
|
-
data = json.load(f)
|
|
9781
|
-
_registry_cache = data.get("vms", {})
|
|
9782
|
-
_registry_cache_time = now
|
|
9783
|
-
return _registry_cache
|
|
9784
|
-
except Exception as e:
|
|
9785
|
-
ctx.log.warn(f"Failed to load VM registry: {e}")
|
|
9786
|
-
|
|
9787
|
-
return _registry_cache
|
|
9788
|
-
|
|
9789
|
-
|
|
9790
|
-
def get_vm_info(client_ip: str) -> dict | None:
|
|
9791
|
-
"""Look up VM info by client IP address."""
|
|
9792
|
-
registry = load_registry()
|
|
9793
|
-
return registry.get(client_ip)
|
|
9794
|
-
|
|
9795
|
-
|
|
9796
|
-
def get_network_log_path(run_id: str) -> str:
|
|
9797
|
-
"""Get the network log file path for a run."""
|
|
9798
|
-
return f"/tmp/vm0-network-{run_id}.jsonl"
|
|
9799
|
-
|
|
9800
|
-
|
|
9801
|
-
def log_network_entry(run_id: str, entry: dict) -> None:
|
|
9802
|
-
"""Write a network log entry to the per-run JSONL file."""
|
|
9803
|
-
if not run_id:
|
|
9804
|
-
return
|
|
9805
|
-
|
|
9806
|
-
log_path = get_network_log_path(run_id)
|
|
9807
|
-
try:
|
|
9808
|
-
fd = os.open(log_path, os.O_CREAT | os.O_APPEND | os.O_WRONLY, 0o644)
|
|
9809
|
-
try:
|
|
9810
|
-
os.write(fd, (json.dumps(entry) + "\\n").encode())
|
|
9811
|
-
finally:
|
|
9812
|
-
os.close(fd)
|
|
9813
|
-
except Exception as e:
|
|
9814
|
-
ctx.log.warn(f"Failed to write network log: {e}")
|
|
9815
|
-
|
|
9816
|
-
|
|
9817
|
-
def get_original_url(flow: http.HTTPFlow) -> str:
|
|
9818
|
-
"""Reconstruct the original target URL from the request."""
|
|
9819
|
-
scheme = "https" if flow.request.port == 443 else "http"
|
|
9820
|
-
host = flow.request.pretty_host
|
|
9821
|
-
port = flow.request.port
|
|
9822
|
-
|
|
9823
|
-
if (scheme == "https" and port != 443) or (scheme == "http" and port != 80):
|
|
9824
|
-
host_with_port = f"{host}:{port}"
|
|
9825
|
-
else:
|
|
9826
|
-
host_with_port = host
|
|
9827
|
-
|
|
9828
|
-
path = flow.request.path
|
|
9829
|
-
return f"{scheme}://{host_with_port}{path}"
|
|
9830
|
-
|
|
9831
|
-
|
|
9832
|
-
# ============================================================================
|
|
9833
|
-
# Firewall Rule Matching
|
|
9834
|
-
# ============================================================================
|
|
9835
|
-
|
|
9836
|
-
def match_domain(pattern: str, hostname: str) -> bool:
|
|
9837
|
-
"""
|
|
9838
|
-
Match hostname against domain pattern.
|
|
9839
|
-
Supports exact match and wildcard prefix (*.example.com).
|
|
9840
|
-
"""
|
|
9841
|
-
if not pattern or not hostname:
|
|
9842
|
-
return False
|
|
9843
|
-
|
|
9844
|
-
pattern = pattern.lower()
|
|
9845
|
-
hostname = hostname.lower()
|
|
9846
|
-
|
|
9847
|
-
if pattern.startswith("*."):
|
|
9848
|
-
# Wildcard: *.example.com matches sub.example.com, www.example.com
|
|
9849
|
-
# Also matches example.com itself (without subdomain)
|
|
9850
|
-
suffix = pattern[1:] # .example.com
|
|
9851
|
-
base = pattern[2:] # example.com
|
|
9852
|
-
return hostname.endswith(suffix) or hostname == base
|
|
9853
|
-
|
|
9854
|
-
return hostname == pattern
|
|
9855
|
-
|
|
9856
|
-
|
|
9857
|
-
def match_ip(cidr: str, ip_str: str) -> bool:
|
|
7380
|
+
def match_ip(cidr: str, ip_str: str) -> bool:
|
|
9858
7381
|
"""
|
|
9859
7382
|
Match IP address against CIDR range.
|
|
9860
7383
|
Supports single IPs (1.2.3.4) and ranges (10.0.0.0/8).
|
|
@@ -10602,10 +8125,9 @@ async function withSandboxTiming(actionType, fn) {
|
|
|
10602
8125
|
}
|
|
10603
8126
|
}
|
|
10604
8127
|
|
|
10605
|
-
// src/lib/executor.ts
|
|
10606
|
-
|
|
10607
|
-
|
|
10608
|
-
}
|
|
8128
|
+
// src/lib/executor-env.ts
|
|
8129
|
+
var ENV_JSON_PATH = "/tmp/vm0-env.json";
|
|
8130
|
+
var PROXY_CA_CERT_PATH = "/opt/vm0-runner/proxy/mitmproxy-ca-cert.pem";
|
|
10609
8131
|
function buildEnvironmentVariables(context, apiUrl) {
|
|
10610
8132
|
const envVars = {
|
|
10611
8133
|
VM0_API_URL: apiUrl,
|
|
@@ -10649,7 +8171,9 @@ function buildEnvironmentVariables(context, apiUrl) {
|
|
|
10649
8171
|
}
|
|
10650
8172
|
return envVars;
|
|
10651
8173
|
}
|
|
10652
|
-
|
|
8174
|
+
|
|
8175
|
+
// src/lib/network-logs/network-logs.ts
|
|
8176
|
+
import fs6 from "fs";
|
|
10653
8177
|
function getNetworkLogPath(runId) {
|
|
10654
8178
|
return `/tmp/vm0-network-${runId}.jsonl`;
|
|
10655
8179
|
}
|
|
@@ -10714,16 +8238,30 @@ async function uploadNetworkLogs(apiUrl, sandboxToken, runId) {
|
|
|
10714
8238
|
console.log(`[Executor] Network logs uploaded successfully for ${runId}`);
|
|
10715
8239
|
cleanupNetworkLogs(runId);
|
|
10716
8240
|
}
|
|
8241
|
+
|
|
8242
|
+
// src/lib/vm-setup/vm-setup.ts
|
|
8243
|
+
import fs7 from "fs";
|
|
8244
|
+
|
|
8245
|
+
// src/lib/scripts/utils.ts
|
|
8246
|
+
function getAllScripts() {
|
|
8247
|
+
return [
|
|
8248
|
+
{ content: RUN_AGENT_SCRIPT, path: SCRIPT_PATHS.runAgent },
|
|
8249
|
+
{ content: DOWNLOAD_SCRIPT, path: SCRIPT_PATHS.download },
|
|
8250
|
+
{ content: MOCK_CLAUDE_SCRIPT, path: SCRIPT_PATHS.mockClaude },
|
|
8251
|
+
// Env loader is runner-specific (loads env from JSON before executing run-agent.mjs)
|
|
8252
|
+
{ content: ENV_LOADER_SCRIPT, path: ENV_LOADER_PATH }
|
|
8253
|
+
];
|
|
8254
|
+
}
|
|
8255
|
+
|
|
8256
|
+
// src/lib/vm-setup/vm-setup.ts
|
|
10717
8257
|
async function uploadScripts(ssh) {
|
|
10718
8258
|
const scripts = getAllScripts();
|
|
10719
|
-
await ssh.execOrThrow(
|
|
10720
|
-
`sudo mkdir -p ${SCRIPT_PATHS.baseDir} ${SCRIPT_PATHS.libDir}`
|
|
10721
|
-
);
|
|
8259
|
+
await ssh.execOrThrow(`sudo mkdir -p ${SCRIPT_PATHS.baseDir}`);
|
|
10722
8260
|
for (const script of scripts) {
|
|
10723
8261
|
await ssh.writeFileWithSudo(script.path, script.content);
|
|
10724
8262
|
}
|
|
10725
8263
|
await ssh.execOrThrow(
|
|
10726
|
-
`sudo chmod +x ${SCRIPT_PATHS.baseDir}/*.
|
|
8264
|
+
`sudo chmod +x ${SCRIPT_PATHS.baseDir}/*.mjs 2>/dev/null || true`
|
|
10727
8265
|
);
|
|
10728
8266
|
}
|
|
10729
8267
|
async function downloadStorages(ssh, manifest) {
|
|
@@ -10736,7 +8274,7 @@ async function downloadStorages(ssh, manifest) {
|
|
|
10736
8274
|
const manifestJson = JSON.stringify(manifest);
|
|
10737
8275
|
await ssh.writeFile("/tmp/storage-manifest.json", manifestJson);
|
|
10738
8276
|
const result = await ssh.exec(
|
|
10739
|
-
`
|
|
8277
|
+
`node ${SCRIPT_PATHS.download} /tmp/storage-manifest.json`
|
|
10740
8278
|
);
|
|
10741
8279
|
if (result.exitCode !== 0) {
|
|
10742
8280
|
throw new Error(`Storage download failed: ${result.stderr}`);
|
|
@@ -10763,14 +8301,13 @@ async function restoreSessionHistory(ssh, resumeSession, workingDir, cliAgentTyp
|
|
|
10763
8301
|
`[Executor] Session history restored (${sessionHistory.split("\n").length} lines)`
|
|
10764
8302
|
);
|
|
10765
8303
|
}
|
|
10766
|
-
var PROXY_CA_CERT_PATH = "/opt/vm0-runner/proxy/mitmproxy-ca-cert.pem";
|
|
10767
8304
|
async function installProxyCA(ssh) {
|
|
10768
|
-
if (!
|
|
8305
|
+
if (!fs7.existsSync(PROXY_CA_CERT_PATH)) {
|
|
10769
8306
|
throw new Error(
|
|
10770
8307
|
`Proxy CA certificate not found at ${PROXY_CA_CERT_PATH}. Run generate-proxy-ca.sh first.`
|
|
10771
8308
|
);
|
|
10772
8309
|
}
|
|
10773
|
-
const caCert =
|
|
8310
|
+
const caCert = fs7.readFileSync(PROXY_CA_CERT_PATH, "utf-8");
|
|
10774
8311
|
console.log(
|
|
10775
8312
|
`[Executor] Installing proxy CA certificate (${caCert.length} bytes)`
|
|
10776
8313
|
);
|
|
@@ -10789,6 +8326,61 @@ nameserver 1.1.1.1`;
|
|
|
10789
8326
|
`sudo sh -c 'rm -f /etc/resolv.conf && echo "${dnsConfig}" > /etc/resolv.conf'`
|
|
10790
8327
|
);
|
|
10791
8328
|
}
|
|
8329
|
+
|
|
8330
|
+
// src/lib/executor.ts
|
|
8331
|
+
function getVmIdFromRunId(runId) {
|
|
8332
|
+
return runId.split("-")[0] || runId.substring(0, 8);
|
|
8333
|
+
}
|
|
8334
|
+
var CURL_ERROR_MESSAGES = {
|
|
8335
|
+
6: "DNS resolution failed",
|
|
8336
|
+
7: "Connection refused",
|
|
8337
|
+
28: "Connection timeout",
|
|
8338
|
+
60: "TLS certificate error (proxy CA not trusted)",
|
|
8339
|
+
22: "HTTP error from server"
|
|
8340
|
+
};
|
|
8341
|
+
async function runPreflightCheck(ssh, apiUrl, runId, sandboxToken, bypassSecret) {
|
|
8342
|
+
const heartbeatUrl = `${apiUrl}/api/webhooks/agent/heartbeat`;
|
|
8343
|
+
const bypassHeader = bypassSecret ? ` -H "x-vercel-protection-bypass: ${bypassSecret}"` : "";
|
|
8344
|
+
const curlCmd = `curl -sf --connect-timeout 5 --max-time 10 "${heartbeatUrl}" -X POST -H "Content-Type: application/json" -H "Authorization: Bearer ${sandboxToken}"${bypassHeader} -d '{"runId":"${runId}"}'`;
|
|
8345
|
+
const result = await ssh.exec(curlCmd, 2e4);
|
|
8346
|
+
if (result.exitCode === 0) {
|
|
8347
|
+
return { success: true };
|
|
8348
|
+
}
|
|
8349
|
+
const errorDetail = CURL_ERROR_MESSAGES[result.exitCode] ?? `curl exit code ${result.exitCode}`;
|
|
8350
|
+
const stderrInfo = result.stderr?.trim() ? ` (${result.stderr.trim()})` : "";
|
|
8351
|
+
return {
|
|
8352
|
+
success: false,
|
|
8353
|
+
error: `Preflight check failed: ${errorDetail}${stderrInfo} - VM cannot reach VM0 API at ${apiUrl}`
|
|
8354
|
+
};
|
|
8355
|
+
}
|
|
8356
|
+
async function reportPreflightFailure(apiUrl, runId, sandboxToken, error, bypassSecret) {
|
|
8357
|
+
const completeUrl = `${apiUrl}/api/webhooks/agent/complete`;
|
|
8358
|
+
const headers = {
|
|
8359
|
+
"Content-Type": "application/json",
|
|
8360
|
+
Authorization: `Bearer ${sandboxToken}`
|
|
8361
|
+
};
|
|
8362
|
+
if (bypassSecret) {
|
|
8363
|
+
headers["x-vercel-protection-bypass"] = bypassSecret;
|
|
8364
|
+
}
|
|
8365
|
+
try {
|
|
8366
|
+
const response = await fetch(completeUrl, {
|
|
8367
|
+
method: "POST",
|
|
8368
|
+
headers,
|
|
8369
|
+
body: JSON.stringify({
|
|
8370
|
+
runId,
|
|
8371
|
+
exitCode: 1,
|
|
8372
|
+
error
|
|
8373
|
+
})
|
|
8374
|
+
});
|
|
8375
|
+
if (!response.ok) {
|
|
8376
|
+
console.error(
|
|
8377
|
+
`[Executor] Failed to report preflight failure: HTTP ${response.status}`
|
|
8378
|
+
);
|
|
8379
|
+
}
|
|
8380
|
+
} catch (err) {
|
|
8381
|
+
console.error(`[Executor] Failed to report preflight failure: ${err}`);
|
|
8382
|
+
}
|
|
8383
|
+
}
|
|
10792
8384
|
async function executeJob(context, config, options = {}) {
|
|
10793
8385
|
const vmId = getVmIdFromRunId(context.runId);
|
|
10794
8386
|
let vm = null;
|
|
@@ -10867,6 +8459,32 @@ async function executeJob(context, config, options = {}) {
|
|
|
10867
8459
|
`[Executor] Writing env JSON (${envJson.length} bytes) to ${ENV_JSON_PATH}`
|
|
10868
8460
|
);
|
|
10869
8461
|
await ssh.writeFile(ENV_JSON_PATH, envJson);
|
|
8462
|
+
if (!options.benchmarkMode) {
|
|
8463
|
+
log(`[Executor] Running preflight connectivity check...`);
|
|
8464
|
+
const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
|
|
8465
|
+
const preflight = await runPreflightCheck(
|
|
8466
|
+
ssh,
|
|
8467
|
+
config.server.url,
|
|
8468
|
+
context.runId,
|
|
8469
|
+
context.sandboxToken,
|
|
8470
|
+
bypassSecret
|
|
8471
|
+
);
|
|
8472
|
+
if (!preflight.success) {
|
|
8473
|
+
log(`[Executor] Preflight check failed: ${preflight.error}`);
|
|
8474
|
+
await reportPreflightFailure(
|
|
8475
|
+
config.server.url,
|
|
8476
|
+
context.runId,
|
|
8477
|
+
context.sandboxToken,
|
|
8478
|
+
preflight.error,
|
|
8479
|
+
bypassSecret
|
|
8480
|
+
);
|
|
8481
|
+
return {
|
|
8482
|
+
exitCode: 1,
|
|
8483
|
+
error: preflight.error
|
|
8484
|
+
};
|
|
8485
|
+
}
|
|
8486
|
+
log(`[Executor] Preflight check passed`);
|
|
8487
|
+
}
|
|
10870
8488
|
const systemLogFile = `/tmp/vm0-main-${context.runId}.log`;
|
|
10871
8489
|
const exitCodeFile = `/tmp/vm0-exit-${context.runId}`;
|
|
10872
8490
|
const startTime = Date.now();
|
|
@@ -10879,7 +8497,7 @@ async function executeJob(context, config, options = {}) {
|
|
|
10879
8497
|
} else {
|
|
10880
8498
|
log(`[Executor] Running agent via env-loader (background)...`);
|
|
10881
8499
|
await ssh.exec(
|
|
10882
|
-
`nohup sh -c '
|
|
8500
|
+
`nohup sh -c 'node ${ENV_LOADER_PATH}; echo $? > ${exitCodeFile}' > ${systemLogFile} 2>&1 &`
|
|
10883
8501
|
);
|
|
10884
8502
|
log(`[Executor] Agent started in background`);
|
|
10885
8503
|
}
|
|
@@ -10898,7 +8516,7 @@ async function executeJob(context, config, options = {}) {
|
|
|
10898
8516
|
}
|
|
10899
8517
|
if (!options.benchmarkMode) {
|
|
10900
8518
|
const processCheck = await ssh.exec(
|
|
10901
|
-
`pgrep -f "env-loader.
|
|
8519
|
+
`pgrep -f "env-loader.mjs" > /dev/null 2>&1 && echo "RUNNING" || echo "DEAD"`
|
|
10902
8520
|
);
|
|
10903
8521
|
if (processCheck.stdout.trim() === "DEAD") {
|
|
10904
8522
|
log(
|
|
@@ -11331,7 +8949,7 @@ var benchmarkCommand = new Command3("benchmark").description(
|
|
|
11331
8949
|
});
|
|
11332
8950
|
|
|
11333
8951
|
// src/index.ts
|
|
11334
|
-
var version = true ? "2.
|
|
8952
|
+
var version = true ? "2.10.0" : "0.1.0";
|
|
11335
8953
|
program.name("vm0-runner").version(version).description("Self-hosted runner for VM0 agents");
|
|
11336
8954
|
program.addCommand(startCommand);
|
|
11337
8955
|
program.addCommand(statusCommand);
|