grasp-sdk 0.1.6__tar.gz → 0.1.8__tar.gz
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.
Potentially problematic release.
This version of grasp-sdk might be problematic. Click here for more details.
- {grasp_sdk-0.1.6/grasp_sdk.egg-info → grasp_sdk-0.1.8}/PKG-INFO +1 -1
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/grasp_sdk/__init__.py +4 -9
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/grasp_sdk/models/__init__.py +0 -2
- grasp_sdk-0.1.8/grasp_sdk/sandbox/bootstrap-chrome-stable.mjs +69 -0
- grasp_sdk-0.1.8/grasp_sdk/sandbox/http-proxy.mjs +324 -0
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/grasp_sdk/services/browser.py +36 -47
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/grasp_sdk/services/sandbox.py +18 -21
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/grasp_sdk/utils/config.py +0 -4
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8/grasp_sdk.egg-info}/PKG-INFO +1 -1
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/grasp_sdk.egg-info/SOURCES.txt +2 -0
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/pyproject.toml +1 -1
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/MANIFEST.in +0 -0
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/README.md +0 -0
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/build_and_publish.py +0 -0
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/example_usage.py +0 -0
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/grasp_sdk/sandbox/chrome-stable.mjs +0 -0
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/grasp_sdk/sandbox/chromium.mjs +0 -0
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/grasp_sdk/services/__init__.py +0 -0
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/grasp_sdk/utils/__init__.py +0 -0
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/grasp_sdk/utils/auth.py +0 -0
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/grasp_sdk/utils/logger.py +0 -0
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/grasp_sdk.egg-info/dependency_links.txt +0 -0
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/grasp_sdk.egg-info/entry_points.txt +0 -0
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/grasp_sdk.egg-info/not-zip-safe +0 -0
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/grasp_sdk.egg-info/requires.txt +0 -0
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/grasp_sdk.egg-info/top_level.txt +0 -0
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/py.typed +0 -0
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/requirements.txt +0 -0
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/setup.cfg +0 -0
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/setup.py +0 -0
- {grasp_sdk-0.1.6 → grasp_sdk-0.1.8}/test_install.py +0 -0
|
@@ -24,7 +24,7 @@ from .models import (
|
|
|
24
24
|
SandboxStatus,
|
|
25
25
|
)
|
|
26
26
|
|
|
27
|
-
__version__ = "0.1.
|
|
27
|
+
__version__ = "0.1.8"
|
|
28
28
|
__author__ = "Grasp Team"
|
|
29
29
|
__email__ = "team@grasp.dev"
|
|
30
30
|
|
|
@@ -76,9 +76,7 @@ class GraspServer:
|
|
|
76
76
|
|
|
77
77
|
self.browser_service: Optional[BrowserService] = None
|
|
78
78
|
|
|
79
|
-
self.logger.info(
|
|
80
|
-
f'GraspE2B initialized (templateId: {config["sandbox"]["templateId"]})'
|
|
81
|
-
)
|
|
79
|
+
self.logger.info('GraspE2B initialized')
|
|
82
80
|
|
|
83
81
|
async def __aenter__(self):
|
|
84
82
|
connection = await self.create_browser_task()
|
|
@@ -147,7 +145,6 @@ class GraspServer:
|
|
|
147
145
|
|
|
148
146
|
# Create base browser config
|
|
149
147
|
browser_config: IBrowserConfig = {
|
|
150
|
-
'cdpPort': 9222,
|
|
151
148
|
'headless': True,
|
|
152
149
|
'launchTimeout': 30000,
|
|
153
150
|
'args': [
|
|
@@ -158,8 +155,6 @@ class GraspServer:
|
|
|
158
155
|
}
|
|
159
156
|
|
|
160
157
|
# Apply user config overrides with type safety
|
|
161
|
-
if 'cdpPort' in config:
|
|
162
|
-
browser_config['cdpPort'] = config['cdpPort']
|
|
163
158
|
if 'headless' in config:
|
|
164
159
|
browser_config['headless'] = config['headless']
|
|
165
160
|
if 'launchTimeout' in config:
|
|
@@ -173,7 +168,7 @@ class GraspServer:
|
|
|
173
168
|
self.config['sandbox'],
|
|
174
169
|
browser_config
|
|
175
170
|
)
|
|
176
|
-
await self.browser_service.initialize()
|
|
171
|
+
await self.browser_service.initialize(browser_type)
|
|
177
172
|
|
|
178
173
|
# Register server
|
|
179
174
|
_servers[str(self.browser_service.id)] = self
|
|
@@ -182,7 +177,7 @@ class GraspServer:
|
|
|
182
177
|
})
|
|
183
178
|
|
|
184
179
|
self.logger.info('🌐 Launching Chromium browser with CDP...')
|
|
185
|
-
cdp_connection = await self.browser_service.launch_browser(
|
|
180
|
+
cdp_connection = await self.browser_service.launch_browser()
|
|
186
181
|
|
|
187
182
|
self.logger.info('✅ Browser launched successfully!')
|
|
188
183
|
self.logger.debug(
|
|
@@ -20,7 +20,6 @@ class SandboxStatus(Enum):
|
|
|
20
20
|
class ISandboxConfig(TypedDict):
|
|
21
21
|
"""Sandbox configuration interface."""
|
|
22
22
|
key: str # Required: Grasp API key
|
|
23
|
-
templateId: str # Required: Sandbox template ID
|
|
24
23
|
timeout: int # Required: Default timeout in milliseconds
|
|
25
24
|
workspace: NotRequired[str] # Optional: Grasp workspace ID
|
|
26
25
|
debug: NotRequired[bool] # Optional: Enable debug mode for detailed logging
|
|
@@ -28,7 +27,6 @@ class ISandboxConfig(TypedDict):
|
|
|
28
27
|
|
|
29
28
|
class IBrowserConfig(TypedDict):
|
|
30
29
|
"""Browser service configuration interface."""
|
|
31
|
-
cdpPort: int # Required: Port for CDP server (default: 9222)
|
|
32
30
|
args: List[str] # Required: Chromium launch arguments
|
|
33
31
|
headless: bool # Required: Headless mode (default: true)
|
|
34
32
|
launchTimeout: int # Required: Timeout for browser launch (default: 30000ms)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
|
|
3
|
+
// const asblockPlugin = '/home/user/.config/google-chrome/Default/Extensions/adblock';
|
|
4
|
+
|
|
5
|
+
const args = [
|
|
6
|
+
'--no-sandbox',
|
|
7
|
+
'--disable-setuid-sandbox',
|
|
8
|
+
'--disable-dev-shm-usage',
|
|
9
|
+
'--disable-gpu',
|
|
10
|
+
'--disable-software-rasterizer',
|
|
11
|
+
'--user-data-dir=/home/user/.browser-context'
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
args.push(
|
|
15
|
+
// 避免缓存积累影响性能
|
|
16
|
+
'--disable-application-cache',
|
|
17
|
+
|
|
18
|
+
// 关闭所有硬件加速特性,防止 GPU 相关崩溃
|
|
19
|
+
'--disable-accelerated-2d-canvas',
|
|
20
|
+
'--disable-accelerated-video-decode',
|
|
21
|
+
|
|
22
|
+
// 禁用后台渲染,减少无关资源消耗
|
|
23
|
+
'--disable-background-timer-throttling',
|
|
24
|
+
'--disable-backgrounding-occluded-windows',
|
|
25
|
+
'--disable-renderer-backgrounding',
|
|
26
|
+
|
|
27
|
+
// 避免过度日志影响性能
|
|
28
|
+
'--disable-logging',
|
|
29
|
+
|
|
30
|
+
// 禁用不必要的多媒体解码
|
|
31
|
+
'--mute-audio',
|
|
32
|
+
|
|
33
|
+
// 避免崩溃时弹窗
|
|
34
|
+
'--no-default-browser-check',
|
|
35
|
+
'--no-first-run',
|
|
36
|
+
'--headless=new',
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
args.push(
|
|
40
|
+
`--remote-debugging-port=9222`,
|
|
41
|
+
'--remote-debugging-address=0.0.0.0',
|
|
42
|
+
'about:blank',
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const chromePath = '/usr/bin/google-chrome';
|
|
46
|
+
|
|
47
|
+
// 启动 Chrome 并启用远程调试
|
|
48
|
+
const chrome = spawn(chromePath, args, {
|
|
49
|
+
env: { ...process.env, DISPLAY: ':99' }
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
chrome.stdout.on('data', (data) => {
|
|
53
|
+
console.log(`stdout: ${data}`);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
chrome.stderr.on('data', (data) => {
|
|
57
|
+
console.error(`stderr: ${data}`);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
chrome.on('close', (code) => {
|
|
61
|
+
console.log(`Chrome process exited with code ${code}`);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
console.log('Browser launched and ready...');
|
|
65
|
+
|
|
66
|
+
// 保持进程不退出,并监听中止信号
|
|
67
|
+
process.stdin.resume();
|
|
68
|
+
process.on('SIGINT', () => process.exit());
|
|
69
|
+
process.on('SIGTERM', () => process.exit());
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import httpProxy from 'http-proxy';
|
|
2
|
+
import http from 'http';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
|
|
5
|
+
import { Logtail } from '@logtail/node';
|
|
6
|
+
import * as Sentry from "@sentry/node";
|
|
7
|
+
|
|
8
|
+
const logtail = new Logtail(process.env.BS_SOURCE_TOKEN, {
|
|
9
|
+
endpoint: `https://${process.env.BS_INGESTING_HOST}`,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
Sentry.init({
|
|
13
|
+
dsn: process.env.SENTRY_DSN,
|
|
14
|
+
|
|
15
|
+
// Setting this option to true will send default PII data to Sentry.
|
|
16
|
+
// For example, automatic IP address collection on events
|
|
17
|
+
sendDefaultPii: true,
|
|
18
|
+
_experiments: {
|
|
19
|
+
enableLogs: true, // 启用日志功能
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const logger = {
|
|
24
|
+
info: async (message, context) => {
|
|
25
|
+
Sentry.logger.info(message, context);
|
|
26
|
+
return logtail.info(message, context);
|
|
27
|
+
},
|
|
28
|
+
warn: async (message, context) => {
|
|
29
|
+
Sentry.logger.warn(message, context);
|
|
30
|
+
return logtail.warn(message, context);
|
|
31
|
+
},
|
|
32
|
+
error: async (message, context) => {
|
|
33
|
+
Sentry.logger.error(message, context);
|
|
34
|
+
return logtail.error(message, context);
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseWebSocketFrame(buffer) {
|
|
39
|
+
if (buffer.length < 2) {
|
|
40
|
+
throw new Error('Incomplete WebSocket frame.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const firstByte = buffer.readUInt8(0);
|
|
44
|
+
const fin = (firstByte & 0x80) !== 0;
|
|
45
|
+
const opcode = firstByte & 0x0f;
|
|
46
|
+
|
|
47
|
+
// 仅处理文本帧(opcode 为 0x1)
|
|
48
|
+
if (opcode !== 0x1) {
|
|
49
|
+
throw new Error(`Unsupported opcode: ${opcode}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const secondByte = buffer.readUInt8(1);
|
|
53
|
+
const isMasked = (secondByte & 0x80) !== 0;
|
|
54
|
+
let payloadLength = secondByte & 0x7f;
|
|
55
|
+
let offset = 2;
|
|
56
|
+
|
|
57
|
+
if (payloadLength === 126) {
|
|
58
|
+
if (buffer.length < offset + 2) {
|
|
59
|
+
throw new Error('Incomplete extended payload length.');
|
|
60
|
+
}
|
|
61
|
+
payloadLength = buffer.readUInt16BE(offset);
|
|
62
|
+
offset += 2;
|
|
63
|
+
} else if (payloadLength === 127) {
|
|
64
|
+
if (buffer.length < offset + 8) {
|
|
65
|
+
throw new Error('Incomplete extended payload length.');
|
|
66
|
+
}
|
|
67
|
+
// 注意:JavaScript 无法精确表示超过 2^53 的整数
|
|
68
|
+
const highBits = buffer.readUInt32BE(offset);
|
|
69
|
+
const lowBits = buffer.readUInt32BE(offset + 4);
|
|
70
|
+
payloadLength = highBits * 2 ** 32 + lowBits;
|
|
71
|
+
offset += 8;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let maskingKey;
|
|
75
|
+
if (isMasked) {
|
|
76
|
+
if (buffer.length < offset + 4) {
|
|
77
|
+
throw new Error('Incomplete masking key.');
|
|
78
|
+
}
|
|
79
|
+
maskingKey = buffer.slice(offset, offset + 4);
|
|
80
|
+
offset += 4;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (buffer.length < offset + payloadLength) {
|
|
84
|
+
throw new Error('Incomplete payload data.');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const payloadData = buffer.slice(offset, offset + payloadLength);
|
|
88
|
+
|
|
89
|
+
if (isMasked) {
|
|
90
|
+
for (let i = 0; i < payloadLength; i++) {
|
|
91
|
+
payloadData[i] ^= maskingKey[i % 4];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return payloadData.toString('utf8');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let sandboxId = '';
|
|
99
|
+
const cdpPort = Number(process.env.CDP_PORT);
|
|
100
|
+
const headless = process.env.HEADLESS !== 'false';
|
|
101
|
+
const enableAdblock = process.env.ADBLOCK !== 'false';
|
|
102
|
+
const timeoutMS = process.env.SANBOX_TIMEOUT;
|
|
103
|
+
const workspace = process.env.WORKSPACE;
|
|
104
|
+
const keepAliveMS = Number(process.env.KEEP_ALIVE_MS) || 0;
|
|
105
|
+
const args = [];
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
// 创建代理服务:从 ${this.config.cdpPort! + 1} 转发到 127.0.0.1:${this.config.cdpPort!}
|
|
109
|
+
const proxy = httpProxy.createProxyServer({
|
|
110
|
+
target: `http://127.0.0.1:${cdpPort}`,
|
|
111
|
+
ws: true, // 支持 WebSocket
|
|
112
|
+
changeOrigin: true
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const clients = new Set();
|
|
116
|
+
|
|
117
|
+
// 监听 WebSocket 事件
|
|
118
|
+
proxy.on('open', () => {
|
|
119
|
+
sandboxId = fs.readFileSync('/home/user/.sandbox_id', 'utf-8');
|
|
120
|
+
console.log('🔌 CDP WebSocket connection established', sandboxId);
|
|
121
|
+
const wsId = Date.now();
|
|
122
|
+
logger.info('CDP WebSocket connection established', { sandboxId, wsId });
|
|
123
|
+
Sentry.addBreadcrumb({
|
|
124
|
+
category: 'websocket',
|
|
125
|
+
message: 'CDP connection established',
|
|
126
|
+
level: 'info',
|
|
127
|
+
data: { sandboxId }
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
clients.add(wsId);
|
|
131
|
+
proxy.once('close', async (req, socket, head) => {
|
|
132
|
+
console.log('🔒 CDP WebSocket connection closed');
|
|
133
|
+
await logger.info('CDP WebSocket connection closed', { sandboxId });
|
|
134
|
+
Sentry.addBreadcrumb({
|
|
135
|
+
category: 'websocket',
|
|
136
|
+
message: 'CDP connection closed',
|
|
137
|
+
level: 'info',
|
|
138
|
+
data: { sandboxId }
|
|
139
|
+
});
|
|
140
|
+
clients.delete(wsId);
|
|
141
|
+
|
|
142
|
+
if(clients.size <= 0) {
|
|
143
|
+
setTimeout(() => {
|
|
144
|
+
if(clients.size <= 0) {
|
|
145
|
+
console.log('❌ Force closed...', wsId);
|
|
146
|
+
process.exit(0);
|
|
147
|
+
}
|
|
148
|
+
}, keepAliveMS);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
proxy.on('proxyReqWs', (proxyReq, req, socket, options, head) => {
|
|
154
|
+
console.log('📡 New CDP WebSocket connection request:', req.url);
|
|
155
|
+
logger.info('New CDP WebSocket connection request', { url: req.url, sandboxId });
|
|
156
|
+
Sentry.addBreadcrumb({
|
|
157
|
+
category: 'websocket',
|
|
158
|
+
message: 'New CDP connection request',
|
|
159
|
+
level: 'info',
|
|
160
|
+
data: { url: req.url, sandboxId }
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
proxy.on('error', (err, req, res) => {
|
|
165
|
+
console.error('❌ CDP WebSocket proxy error:', err);
|
|
166
|
+
logger.error('CDP WebSocket proxy error', { error: err.message, url: req?.url, sandboxId });
|
|
167
|
+
Sentry.captureException(err, {
|
|
168
|
+
tags: { type: 'websocket_proxy_error', sandboxId },
|
|
169
|
+
extra: { url: req?.url }
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const server = http.createServer(async (req, res) => {
|
|
174
|
+
if (req.url === '/health') {
|
|
175
|
+
res.end('ok');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (req.url === '/json/version' || req.url === '/json/version/') {
|
|
179
|
+
try {
|
|
180
|
+
// 向本地 CDP 发请求,获取原始 JSON
|
|
181
|
+
const jsonRes = await fetch(`http://127.0.0.1:${cdpPort}/json/version`);
|
|
182
|
+
const data = await jsonRes.json();
|
|
183
|
+
// 替换掉本地的 WebSocket 地址为代理暴露地址
|
|
184
|
+
data.webSocketDebuggerUrl = data.webSocketDebuggerUrl.replace(
|
|
185
|
+
`ws://127.0.0.1:${cdpPort}`,
|
|
186
|
+
`wss://${req.headers.host}`
|
|
187
|
+
);
|
|
188
|
+
await logger.info('CDP version info requested', { url: req.url, response: data, sandboxId });
|
|
189
|
+
Sentry.addBreadcrumb({
|
|
190
|
+
category: 'http',
|
|
191
|
+
message: 'CDP version info requested',
|
|
192
|
+
level: 'info',
|
|
193
|
+
data: { url: req.url, response: data, sandboxId }
|
|
194
|
+
});
|
|
195
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
196
|
+
res.end(JSON.stringify(data));
|
|
197
|
+
} catch(ex) {
|
|
198
|
+
console.error('Failed to fetch CDP version:', ex.message);
|
|
199
|
+
await logger.error('Failed to fetch CDP version', { error: ex.message, sandboxId });
|
|
200
|
+
Sentry.captureException(ex, {
|
|
201
|
+
tags: { type: 'cdp_version_error', sandboxId }
|
|
202
|
+
});
|
|
203
|
+
res.writeHead(500);
|
|
204
|
+
res.end('Internal Server Error');
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
proxy.web(req, res, {}, async (err) => {
|
|
208
|
+
console.error('Proxy error:', err);
|
|
209
|
+
await logger.error('HTTP proxy error', { error: err.message, url: req.url, sandboxId });
|
|
210
|
+
Sentry.captureException(err, {
|
|
211
|
+
tags: { type: 'proxy_error', sandboxId },
|
|
212
|
+
extra: { url: req.url }
|
|
213
|
+
});
|
|
214
|
+
res.writeHead(502);
|
|
215
|
+
res.end('Bad gateway');
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
server.on('upgrade', (req, socket, head) => {
|
|
221
|
+
// 监听 WebSocket 数据
|
|
222
|
+
let _buffers = [];
|
|
223
|
+
socket.on('data', (data) => {
|
|
224
|
+
let message = '';
|
|
225
|
+
try {
|
|
226
|
+
_buffers.push(data);
|
|
227
|
+
// console.log(`💬 ${_buffers.length}`);
|
|
228
|
+
message = parseWebSocketFrame(Buffer.concat(_buffers)); // 复制data不能破坏原始数据
|
|
229
|
+
_buffers.length = 0;
|
|
230
|
+
if (message.startsWith('{')){ // 只解析 JSON 消息
|
|
231
|
+
const parsed = JSON.parse(message);
|
|
232
|
+
console.log('📨 CDP WebSocket message:', parsed);
|
|
233
|
+
logger.info('CDP WebSocket message received', {
|
|
234
|
+
data: parsed,
|
|
235
|
+
sandboxId,
|
|
236
|
+
});
|
|
237
|
+
Sentry.addBreadcrumb({
|
|
238
|
+
category: 'websocket',
|
|
239
|
+
message: 'CDP message received',
|
|
240
|
+
level: 'debug',
|
|
241
|
+
data: { ...parsed, sandboxId }
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
} catch (err) {
|
|
245
|
+
const msg = err.message;
|
|
246
|
+
if(!msg.includes('Incomplete')) {
|
|
247
|
+
// 记录解析错误
|
|
248
|
+
console.warn('⚠️ Failed to parse CDP WebSocket message:', err.message, _buffers.length);
|
|
249
|
+
_buffers.length = 0;
|
|
250
|
+
Sentry.captureException(err, {
|
|
251
|
+
tags: { type: 'websocket_error', sandboxId }
|
|
252
|
+
});
|
|
253
|
+
logger.warn('Failed to parse CDP WebSocket message', {
|
|
254
|
+
error: err.message,
|
|
255
|
+
data: message,
|
|
256
|
+
sandboxId,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
socket.on('error', (err) => {
|
|
263
|
+
console.error('❌ CDP WebSocket error:', err);
|
|
264
|
+
logger.error('CDP WebSocket error', { error: err.message, sandboxId });
|
|
265
|
+
Sentry.captureException(err, {
|
|
266
|
+
tags: { type: 'websocket_error', sandboxId }
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
proxy.ws(req, socket, head);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
server.listen(cdpPort + 1, '0.0.0.0', () => {
|
|
274
|
+
console.log(`🎯 Proxy server listening on http://0.0.0.0:${cdpPort + 1} → http://127.0.0.1:${cdpPort}`);
|
|
275
|
+
logger.info('Proxy server started', {
|
|
276
|
+
port: cdpPort + 1,
|
|
277
|
+
target: cdpPort,
|
|
278
|
+
sandboxId,
|
|
279
|
+
settings: {
|
|
280
|
+
type: 'chromium',
|
|
281
|
+
args,
|
|
282
|
+
headless,
|
|
283
|
+
enableAdblock,
|
|
284
|
+
timeoutMS,
|
|
285
|
+
workspace,
|
|
286
|
+
sandboxId
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
Sentry.addBreadcrumb({
|
|
290
|
+
category: 'server',
|
|
291
|
+
message: 'Proxy server started',
|
|
292
|
+
level: 'info',
|
|
293
|
+
data: {
|
|
294
|
+
port: cdpPort + 1,
|
|
295
|
+
target: cdpPort,
|
|
296
|
+
sandboxId,
|
|
297
|
+
settings: {
|
|
298
|
+
type: 'chromium',
|
|
299
|
+
args,
|
|
300
|
+
headless,
|
|
301
|
+
enableAdblock,
|
|
302
|
+
timeoutMS,
|
|
303
|
+
workspace,
|
|
304
|
+
sandboxId
|
|
305
|
+
},
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
} catch (ex) {
|
|
310
|
+
console.error('Failed to launch Chromium:', ex);
|
|
311
|
+
logger.error('Failed to launch Chrome', {
|
|
312
|
+
error: ex.message,
|
|
313
|
+
args,
|
|
314
|
+
headless,
|
|
315
|
+
cdpPort,
|
|
316
|
+
enableAdblock,
|
|
317
|
+
sandboxId,
|
|
318
|
+
});
|
|
319
|
+
Sentry.captureException(ex, {
|
|
320
|
+
tags: { type: 'launch_error', sandboxId },
|
|
321
|
+
extra: { args, headless, cdpPort, enableAdblock }
|
|
322
|
+
});
|
|
323
|
+
process.exit(1);
|
|
324
|
+
}
|
|
@@ -63,7 +63,6 @@ class BrowserService:
|
|
|
63
63
|
|
|
64
64
|
# Set default browser config
|
|
65
65
|
default_config: IBrowserConfig = {
|
|
66
|
-
'cdpPort': 9222,
|
|
67
66
|
'headless': True,
|
|
68
67
|
'launchTimeout': 30000,
|
|
69
68
|
'args': [],
|
|
@@ -92,19 +91,34 @@ class BrowserService:
|
|
|
92
91
|
})
|
|
93
92
|
return default_logger.child('BrowserService')
|
|
94
93
|
|
|
95
|
-
async def initialize(self) -> None:
|
|
94
|
+
async def initialize(self, browser_type: str) -> None:
|
|
96
95
|
"""Initialize the Grasp sandbox.
|
|
97
96
|
|
|
98
97
|
Returns:
|
|
99
98
|
Promise that resolves when sandbox is ready
|
|
100
99
|
"""
|
|
101
100
|
self.logger.info('Initializing Browser service')
|
|
102
|
-
|
|
101
|
+
envs = {
|
|
102
|
+
'CDP_PORT': '9222',
|
|
103
|
+
'BROWSER_ARGS': json.dumps(self.config['args']),
|
|
104
|
+
'LAUNCH_TIMEOUT': str(self.config['launchTimeout']),
|
|
105
|
+
'SANDBOX_TIMEOUT': str(self.sandbox_service.timeout),
|
|
106
|
+
'HEADLESS': str(self.config['headless']).lower(),
|
|
107
|
+
'NODE_ENV': 'production',
|
|
108
|
+
# 'SANDBOX_ID': self.sandbox_service.id,
|
|
109
|
+
'WORKSPACE': self.sandbox_service.workspace,
|
|
110
|
+
'BS_SOURCE_TOKEN': 'Qth8JGboEKVersqr1PSsUFMW',
|
|
111
|
+
'BS_INGESTING_HOST': 's1363065.eu-nbg-2.betterstackdata.com',
|
|
112
|
+
'SENTRY_DSN': 'https://21fa729ceb72d7f0adef06b4f786c067@o4509574910509056.ingest.us.sentry.io/4509574913720320',
|
|
113
|
+
**self.config['envs']
|
|
114
|
+
}
|
|
115
|
+
await self.sandbox_service.create_sandbox(f'grasp-run-{browser_type}', envs)
|
|
116
|
+
if(self.sandbox_service.sandbox is not None):
|
|
117
|
+
await self.sandbox_service.sandbox.files.write('/home/user/.sandbox_id', self.id)
|
|
103
118
|
self.logger.info('Grasp sandbox initialized successfully')
|
|
104
119
|
|
|
105
120
|
async def launch_browser(
|
|
106
121
|
self,
|
|
107
|
-
browser_type: str = 'chromium'
|
|
108
122
|
) -> CDPConnection:
|
|
109
123
|
"""Launch Chromium browser with CDP server.
|
|
110
124
|
|
|
@@ -122,43 +136,18 @@ class BrowserService:
|
|
|
122
136
|
|
|
123
137
|
try:
|
|
124
138
|
self.logger.info(
|
|
125
|
-
f'Launching Chromium browser with CDP (port:
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
# Check if adblock is enabled and adjust browser type
|
|
129
|
-
if (
|
|
130
|
-
self.config['envs'].get('ADBLOCK') == 'true' and
|
|
131
|
-
browser_type == 'chromium'
|
|
132
|
-
):
|
|
133
|
-
self.logger.warn(
|
|
134
|
-
'⚠️ Adblock is enabled. Should use chrome-stable instead.'
|
|
135
|
-
)
|
|
136
|
-
browser_type = 'chrome-stable'
|
|
139
|
+
f'Launching Chromium browser with CDP (port: 9222, headless: {self.config["headless"]})')
|
|
137
140
|
|
|
138
141
|
# Read the Playwright script
|
|
139
|
-
script_path = Path(__file__).parent.parent / 'sandbox' /
|
|
140
|
-
|
|
141
|
-
try:
|
|
142
|
-
with open(script_path, 'r', encoding='utf-8') as f:
|
|
143
|
-
playwright_script = f.read()
|
|
144
|
-
except FileNotFoundError:
|
|
145
|
-
raise RuntimeError(f'Browser script not found: {script_path}')
|
|
142
|
+
# script_path = Path(__file__).parent.parent / 'sandbox' / 'http-proxy.mjs'
|
|
146
143
|
|
|
147
|
-
#
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
'NODE_ENV': 'production',
|
|
155
|
-
'SANDBOX_ID': self.sandbox_service.id,
|
|
156
|
-
'WORKSPACE': self.sandbox_service.workspace,
|
|
157
|
-
'BS_SOURCE_TOKEN': 'Qth8JGboEKVersqr1PSsUFMW',
|
|
158
|
-
'BS_INGESTING_HOST': 's1363065.eu-nbg-2.betterstackdata.com',
|
|
159
|
-
'SENTRY_DSN': 'https://21fa729ceb72d7f0adef06b4f786c067@o4509574910509056.ingest.us.sentry.io/4509574913720320',
|
|
160
|
-
**self.config['envs']
|
|
161
|
-
}
|
|
144
|
+
# try:
|
|
145
|
+
# with open(script_path, 'r', encoding='utf-8') as f:
|
|
146
|
+
# playwright_script = f.read()
|
|
147
|
+
# except FileNotFoundError:
|
|
148
|
+
# raise RuntimeError(f'Browser script not found: {script_path}')
|
|
149
|
+
|
|
150
|
+
playwright_script = '/home/user/http-proxy.mjs'
|
|
162
151
|
|
|
163
152
|
# Prepare script options
|
|
164
153
|
from ..models import IScriptOptions
|
|
@@ -167,7 +156,6 @@ class BrowserService:
|
|
|
167
156
|
'background': True,
|
|
168
157
|
'nohup': not self.sandbox_service.is_debug,
|
|
169
158
|
'timeoutMs': 0,
|
|
170
|
-
'envs': envs,
|
|
171
159
|
'preCommand': '' if self.config['headless'] else 'xvfb-run -a -s "-screen 0 1280x1024x24" '
|
|
172
160
|
}
|
|
173
161
|
|
|
@@ -187,7 +175,7 @@ class BrowserService:
|
|
|
187
175
|
self.cdp_connection = result
|
|
188
176
|
|
|
189
177
|
self.logger.info(
|
|
190
|
-
f'Chromium browser launched successfully (cdpPort:
|
|
178
|
+
f'Chromium browser launched successfully (cdpPort: 9222, wsUrl: {self.cdp_connection.ws_url})'
|
|
191
179
|
)
|
|
192
180
|
|
|
193
181
|
# Start health check if not in debug mode
|
|
@@ -238,7 +226,7 @@ class BrowserService:
|
|
|
238
226
|
Raises:
|
|
239
227
|
RuntimeError: If CDP server fails to become ready within timeout
|
|
240
228
|
"""
|
|
241
|
-
delay_ms =
|
|
229
|
+
delay_ms = 100
|
|
242
230
|
max_attempts = self.config['launchTimeout'] // delay_ms
|
|
243
231
|
|
|
244
232
|
for attempt in range(1, max_attempts + 1):
|
|
@@ -246,6 +234,10 @@ class BrowserService:
|
|
|
246
234
|
self.logger.debug(
|
|
247
235
|
f'Checking CDP availability (attempt {attempt}/{max_attempts})'
|
|
248
236
|
)
|
|
237
|
+
|
|
238
|
+
host = self.sandbox_service.get_sandbox_host(
|
|
239
|
+
9223
|
|
240
|
+
)
|
|
249
241
|
|
|
250
242
|
# Check if CDP endpoint is responding
|
|
251
243
|
options: ICommandOptions = {
|
|
@@ -253,7 +245,7 @@ class BrowserService:
|
|
|
253
245
|
'inBackground': False
|
|
254
246
|
}
|
|
255
247
|
result = await self.sandbox_service.run_command(
|
|
256
|
-
f'curl -s
|
|
248
|
+
f'curl -s https://{host}/json/version',
|
|
257
249
|
options,
|
|
258
250
|
True,
|
|
259
251
|
)
|
|
@@ -264,15 +256,12 @@ class BrowserService:
|
|
|
264
256
|
):
|
|
265
257
|
stdout_content = getattr(result, 'stdout', '')
|
|
266
258
|
metadata = json.loads(stdout_content)
|
|
267
|
-
host = self.sandbox_service.get_sandbox_host(
|
|
268
|
-
self.config['cdpPort'] + 1
|
|
269
|
-
)
|
|
270
259
|
|
|
271
260
|
# Update URLs for external access
|
|
272
261
|
ws_url = metadata['webSocketDebuggerUrl'].replace(
|
|
273
262
|
'ws://', 'wss://'
|
|
274
263
|
).replace(
|
|
275
|
-
f'localhost:
|
|
264
|
+
f'localhost:9222', host
|
|
276
265
|
)
|
|
277
266
|
|
|
278
267
|
http_url = f'https://{host}'
|
|
@@ -280,7 +269,7 @@ class BrowserService:
|
|
|
280
269
|
connection = CDPConnection(
|
|
281
270
|
ws_url=ws_url,
|
|
282
271
|
http_url=http_url,
|
|
283
|
-
port=
|
|
272
|
+
port=9222
|
|
284
273
|
)
|
|
285
274
|
|
|
286
275
|
self.logger.info(f'CDP server is ready (metadata: {metadata})')
|
|
@@ -123,7 +123,7 @@ class SandboxService:
|
|
|
123
123
|
"""Get timeout value."""
|
|
124
124
|
return self.config['timeout']
|
|
125
125
|
|
|
126
|
-
async def create_sandbox(self) -> None:
|
|
126
|
+
async def create_sandbox(self, template_id: str, envs: Optional[Dict[str, str]] = None) -> None:
|
|
127
127
|
"""
|
|
128
128
|
Creates and starts a new sandbox.
|
|
129
129
|
|
|
@@ -143,13 +143,14 @@ class SandboxService:
|
|
|
143
143
|
|
|
144
144
|
# Create sandbox
|
|
145
145
|
self.logger.info('Creating Grasp sandbox', {
|
|
146
|
-
'templateId':
|
|
146
|
+
'templateId': template_id,
|
|
147
147
|
})
|
|
148
148
|
# Use Sandbox constructor directly (e2b SDK 1.5.3+)
|
|
149
149
|
self.sandbox = await AsyncSandbox.create(
|
|
150
|
-
template=
|
|
150
|
+
template=template_id,
|
|
151
151
|
api_key=api_key,
|
|
152
|
-
timeout=self.config['timeout'] // 1000 # Convert ms to seconds
|
|
152
|
+
timeout=self.config['timeout'] // 1000, # Convert ms to seconds
|
|
153
|
+
envs=envs,
|
|
153
154
|
)
|
|
154
155
|
|
|
155
156
|
self.status = SandboxStatus.RUNNING
|
|
@@ -322,7 +323,7 @@ class SandboxService:
|
|
|
322
323
|
Runs JavaScript code in the sandbox.
|
|
323
324
|
|
|
324
325
|
Args:
|
|
325
|
-
code: JavaScript code to execute
|
|
326
|
+
code: JavaScript code to execute, or file path starting with '/home/user/'
|
|
326
327
|
options: Script execution options
|
|
327
328
|
|
|
328
329
|
Returns:
|
|
@@ -331,6 +332,10 @@ class SandboxService:
|
|
|
331
332
|
|
|
332
333
|
Raises:
|
|
333
334
|
RuntimeError: If sandbox is not running or script execution fails
|
|
335
|
+
|
|
336
|
+
Note:
|
|
337
|
+
If code starts with '/home/user/', it will be treated as a file path.
|
|
338
|
+
Otherwise, it will be treated as JavaScript code and written to a temporary file.
|
|
334
339
|
"""
|
|
335
340
|
if not self.sandbox or self.status != SandboxStatus.RUNNING:
|
|
336
341
|
raise RuntimeError('Sandbox is not running. Call create_sandbox() first.')
|
|
@@ -341,9 +346,14 @@ class SandboxService:
|
|
|
341
346
|
try:
|
|
342
347
|
# Generate temporary file name in working directory
|
|
343
348
|
timestamp = int(time.time() * 1000)
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
349
|
+
script_path = code
|
|
350
|
+
|
|
351
|
+
if not code.startswith('/home/user/'):
|
|
352
|
+
extension = 'mjs' if options['type'] == 'esm' else 'js'
|
|
353
|
+
working_dir = options.get('cwd', self.DEFAULT_WORKING_DIRECTORY)
|
|
354
|
+
script_path = f'{working_dir}/script_{timestamp}.{extension}'
|
|
355
|
+
# Write code to temporary file
|
|
356
|
+
await self.sandbox.files.write(script_path, code)
|
|
347
357
|
|
|
348
358
|
self.logger.debug('Running JavaScript code in sandbox', {
|
|
349
359
|
'type': options['type'],
|
|
@@ -351,9 +361,6 @@ class SandboxService:
|
|
|
351
361
|
'codeLength': len(code),
|
|
352
362
|
})
|
|
353
363
|
|
|
354
|
-
# Write code to temporary file
|
|
355
|
-
await self.sandbox.files.write(script_path, code)
|
|
356
|
-
|
|
357
364
|
# Choose execution command based on type
|
|
358
365
|
pre_command = options.get('preCommand', '')
|
|
359
366
|
command = f'{pre_command}node {script_path}'
|
|
@@ -374,16 +381,6 @@ class SandboxService:
|
|
|
374
381
|
# Execute the script
|
|
375
382
|
result = await self.run_command(command, cmd_options)
|
|
376
383
|
|
|
377
|
-
# Cleanup temporary file (if not background execution)
|
|
378
|
-
if not options.get('background', False):
|
|
379
|
-
try:
|
|
380
|
-
await self.run_command(f'rm -f {script_path}')
|
|
381
|
-
except Exception as cleanup_error:
|
|
382
|
-
self.logger.warn('Failed to cleanup script file', {
|
|
383
|
-
'scriptPath': script_path,
|
|
384
|
-
'error': cleanup_error,
|
|
385
|
-
})
|
|
386
|
-
|
|
387
384
|
return result
|
|
388
385
|
|
|
389
386
|
except Exception as error:
|
|
@@ -23,7 +23,6 @@ def get_config() -> Dict[str, Any]:
|
|
|
23
23
|
return {
|
|
24
24
|
'sandbox': {
|
|
25
25
|
'key': os.getenv('GRASP_KEY', ''),
|
|
26
|
-
'templateId': 'playwright-pnpm-template',
|
|
27
26
|
'timeout': int(os.getenv('GRASP_SERVICE_TIMEOUT', '900000')),
|
|
28
27
|
'debug': os.getenv('GRASP_DEBUG', 'false').lower() == 'true',
|
|
29
28
|
},
|
|
@@ -44,7 +43,6 @@ def get_sandbox_config() -> ISandboxConfig:
|
|
|
44
43
|
config = get_config()
|
|
45
44
|
sandbox_config = ISandboxConfig(
|
|
46
45
|
key=config['sandbox']['key'],
|
|
47
|
-
templateId=config['sandbox']['templateId'],
|
|
48
46
|
timeout=config['sandbox']['timeout'],
|
|
49
47
|
debug=config['sandbox'].get('debug', False),
|
|
50
48
|
)
|
|
@@ -64,7 +62,6 @@ def get_browser_config() -> IBrowserConfig:
|
|
|
64
62
|
IBrowserConfig: Browser configuration object.
|
|
65
63
|
"""
|
|
66
64
|
return IBrowserConfig(
|
|
67
|
-
cdpPort=int(os.getenv('GRASP_CDP_PORT', '9222')),
|
|
68
65
|
args=[
|
|
69
66
|
'--no-sandbox',
|
|
70
67
|
'--disable-setuid-sandbox',
|
|
@@ -102,7 +99,6 @@ ENV_VARS = {
|
|
|
102
99
|
'GRASP_DEBUG': 'GRASP_DEBUG',
|
|
103
100
|
'GRASP_LOG_LEVEL': 'GRASP_LOG_LEVEL',
|
|
104
101
|
'GRASP_LOG_FILE': 'GRASP_LOG_FILE',
|
|
105
|
-
'GRASP_CDP_PORT': 'GRASP_CDP_PORT',
|
|
106
102
|
'GRASP_HEADLESS': 'GRASP_HEADLESS',
|
|
107
103
|
'GRASP_LAUNCH_TIMEOUT': 'GRASP_LAUNCH_TIMEOUT',
|
|
108
104
|
}
|
|
@@ -30,8 +30,10 @@ grasp_sdk.egg-info/not-zip-safe
|
|
|
30
30
|
grasp_sdk.egg-info/requires.txt
|
|
31
31
|
grasp_sdk.egg-info/top_level.txt
|
|
32
32
|
grasp_sdk/models/__init__.py
|
|
33
|
+
grasp_sdk/sandbox/bootstrap-chrome-stable.mjs
|
|
33
34
|
grasp_sdk/sandbox/chrome-stable.mjs
|
|
34
35
|
grasp_sdk/sandbox/chromium.mjs
|
|
36
|
+
grasp_sdk/sandbox/http-proxy.mjs
|
|
35
37
|
grasp_sdk/services/__init__.py
|
|
36
38
|
grasp_sdk/services/browser.py
|
|
37
39
|
grasp_sdk/services/sandbox.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|