@giselles-ai/sandbox-agent-core 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +37 -37
- package/dist/index.js +366 -369
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,54 +1,54 @@
|
|
|
1
|
-
import { BridgeRequest, BridgeResponse, BridgeErrorCode } from '@giselles-ai/browser-tool';
|
|
2
|
-
import Redis from 'ioredis';
|
|
3
1
|
import { z } from 'zod';
|
|
2
|
+
import { RelayRequest, RelayResponse, RelayErrorCode } from '@giselles-ai/browser-tool';
|
|
3
|
+
import Redis from 'ioredis';
|
|
4
|
+
|
|
5
|
+
declare const requestSchema: z.ZodObject<{
|
|
6
|
+
message: z.ZodString;
|
|
7
|
+
session_id: z.ZodOptional<z.ZodString>;
|
|
8
|
+
sandbox_id: z.ZodOptional<z.ZodString>;
|
|
9
|
+
relay_session_id: z.ZodString;
|
|
10
|
+
relay_token: z.ZodString;
|
|
11
|
+
}, z.core.$strip>;
|
|
12
|
+
type GeminiChatRouteOptions = {
|
|
13
|
+
requestParser?: typeof requestSchema;
|
|
14
|
+
};
|
|
15
|
+
declare function createGeminiChatHandler(_options?: GeminiChatRouteOptions): (request: Request) => Promise<Response>;
|
|
16
|
+
|
|
17
|
+
declare function createRelayHandler(): {
|
|
18
|
+
GET: (request: Request) => Promise<Response>;
|
|
19
|
+
POST: (request: Request) => Promise<Response>;
|
|
20
|
+
};
|
|
4
21
|
|
|
5
|
-
declare const
|
|
22
|
+
declare const RELAY_SSE_KEEPALIVE_INTERVAL_MS: number;
|
|
6
23
|
declare global {
|
|
7
|
-
var
|
|
24
|
+
var __browserToolRelayRedis: Redis | undefined;
|
|
8
25
|
}
|
|
9
|
-
declare class
|
|
10
|
-
readonly code:
|
|
26
|
+
declare class RelayStoreError extends Error {
|
|
27
|
+
readonly code: RelayErrorCode;
|
|
11
28
|
readonly status: number;
|
|
12
|
-
constructor(code:
|
|
29
|
+
constructor(code: RelayErrorCode, message: string, status: number);
|
|
13
30
|
}
|
|
14
|
-
declare function
|
|
15
|
-
declare function
|
|
16
|
-
declare function
|
|
31
|
+
declare function createRelaySubscriber(): Redis;
|
|
32
|
+
declare function relayRequestChannel(sessionId: string): string;
|
|
33
|
+
declare function createRelaySession(): Promise<{
|
|
17
34
|
sessionId: string;
|
|
18
35
|
token: string;
|
|
19
36
|
expiresAt: number;
|
|
20
37
|
}>;
|
|
21
|
-
declare function
|
|
22
|
-
declare function
|
|
23
|
-
declare function
|
|
24
|
-
declare function
|
|
38
|
+
declare function assertRelaySession(sessionId: string, token: string): Promise<void>;
|
|
39
|
+
declare function markBrowserConnected(sessionId: string, token: string): Promise<void>;
|
|
40
|
+
declare function touchBrowserConnected(sessionId: string): Promise<void>;
|
|
41
|
+
declare function dispatchRelayRequest(input: {
|
|
25
42
|
sessionId: string;
|
|
26
43
|
token: string;
|
|
27
|
-
request:
|
|
44
|
+
request: RelayRequest;
|
|
28
45
|
timeoutMs?: number;
|
|
29
|
-
}): Promise<
|
|
30
|
-
declare function
|
|
46
|
+
}): Promise<RelayResponse>;
|
|
47
|
+
declare function resolveRelayResponse(input: {
|
|
31
48
|
sessionId: string;
|
|
32
49
|
token: string;
|
|
33
|
-
response:
|
|
50
|
+
response: RelayResponse;
|
|
34
51
|
}): Promise<void>;
|
|
35
|
-
declare function
|
|
36
|
-
|
|
37
|
-
declare function createBridgeHandler(): {
|
|
38
|
-
GET: (request: Request) => Promise<Response>;
|
|
39
|
-
POST: (request: Request) => Promise<Response>;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
declare const requestSchema: z.ZodObject<{
|
|
43
|
-
message: z.ZodString;
|
|
44
|
-
session_id: z.ZodOptional<z.ZodString>;
|
|
45
|
-
sandbox_id: z.ZodOptional<z.ZodString>;
|
|
46
|
-
bridge_session_id: z.ZodString;
|
|
47
|
-
bridge_token: z.ZodString;
|
|
48
|
-
}, z.core.$strip>;
|
|
49
|
-
type GeminiChatRouteOptions = {
|
|
50
|
-
requestParser?: typeof requestSchema;
|
|
51
|
-
};
|
|
52
|
-
declare function createGeminiChatHandler(_options?: GeminiChatRouteOptions): (request: Request) => Promise<Response>;
|
|
52
|
+
declare function toRelayError(error: unknown): RelayStoreError;
|
|
53
53
|
|
|
54
|
-
export {
|
|
54
|
+
export { RELAY_SSE_KEEPALIVE_INTERVAL_MS, assertRelaySession, createGeminiChatHandler, createRelayHandler, createRelaySession, createRelaySubscriber, dispatchRelayRequest, markBrowserConnected, relayRequestChannel, resolveRelayResponse, toRelayError, touchBrowserConnected };
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,247 @@
|
|
|
1
|
-
// src/
|
|
1
|
+
// src/chat-handler.ts
|
|
2
|
+
import { Writable } from "stream";
|
|
3
|
+
import { Sandbox } from "@vercel/sandbox";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
var GEMINI_SETTINGS_PATH = "/home/vercel-sandbox/.gemini/settings.json";
|
|
6
|
+
var requestSchema = z.object({
|
|
7
|
+
message: z.string().min(1),
|
|
8
|
+
session_id: z.string().min(1).optional(),
|
|
9
|
+
sandbox_id: z.string().min(1).optional(),
|
|
10
|
+
relay_session_id: z.string().min(1),
|
|
11
|
+
relay_token: z.string().min(1)
|
|
12
|
+
});
|
|
13
|
+
function requiredEnv(name) {
|
|
14
|
+
const value = process.env[name]?.trim();
|
|
15
|
+
if (!value) {
|
|
16
|
+
throw new Error(`Missing required environment variable: ${name}`);
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
function extractTokenFromRequest(request) {
|
|
21
|
+
const oidcToken = request.headers.get("x-vercel-oidc-token")?.trim();
|
|
22
|
+
if (oidcToken) {
|
|
23
|
+
return oidcToken;
|
|
24
|
+
}
|
|
25
|
+
const authorization = request.headers.get("authorization")?.trim();
|
|
26
|
+
if (!authorization) {
|
|
27
|
+
return void 0;
|
|
28
|
+
}
|
|
29
|
+
if (/^bearer\s+/i.test(authorization)) {
|
|
30
|
+
return authorization.replace(/^bearer\s+/i, "").trim();
|
|
31
|
+
}
|
|
32
|
+
return authorization;
|
|
33
|
+
}
|
|
34
|
+
function buildMcpEnv(input) {
|
|
35
|
+
const env = {
|
|
36
|
+
BROWSER_TOOL_RELAY_URL: input.relayUrl,
|
|
37
|
+
BROWSER_TOOL_RELAY_SESSION_ID: input.relaySessionId,
|
|
38
|
+
BROWSER_TOOL_RELAY_TOKEN: input.relayToken
|
|
39
|
+
};
|
|
40
|
+
if (input.oidcToken) {
|
|
41
|
+
env.VERCEL_OIDC_TOKEN = input.oidcToken;
|
|
42
|
+
}
|
|
43
|
+
if (input.vercelProtectionBypass?.trim()) {
|
|
44
|
+
env.VERCEL_PROTECTION_BYPASS = input.vercelProtectionBypass.trim();
|
|
45
|
+
}
|
|
46
|
+
if (input.giselleProtectionBypass?.trim()) {
|
|
47
|
+
env.GISELLE_PROTECTION_BYPASS = input.giselleProtectionBypass.trim();
|
|
48
|
+
}
|
|
49
|
+
return env;
|
|
50
|
+
}
|
|
51
|
+
async function patchGeminiSettingsEnv(sandbox, mcpEnv) {
|
|
52
|
+
const buffer = await sandbox.readFileToBuffer({
|
|
53
|
+
path: GEMINI_SETTINGS_PATH
|
|
54
|
+
});
|
|
55
|
+
if (!buffer) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Gemini settings not found in sandbox at ${GEMINI_SETTINGS_PATH}. Ensure the snapshot contains a pre-configured settings.json.`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
const settings = JSON.parse(new TextDecoder().decode(buffer));
|
|
61
|
+
if (settings.mcpServers) {
|
|
62
|
+
settings.mcpServers = Object.fromEntries(
|
|
63
|
+
Object.entries(settings.mcpServers).map(([key, server]) => [
|
|
64
|
+
key,
|
|
65
|
+
{ ...server, env: { ...server.env, ...mcpEnv } }
|
|
66
|
+
])
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
await sandbox.writeFiles([
|
|
70
|
+
{
|
|
71
|
+
path: GEMINI_SETTINGS_PATH,
|
|
72
|
+
content: Buffer.from(JSON.stringify(settings, null, 2))
|
|
73
|
+
}
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
76
|
+
function emitText(controller, text, encoder) {
|
|
77
|
+
if (text.length === 0) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
controller.enqueue(encoder.encode(text));
|
|
81
|
+
}
|
|
82
|
+
function emitEvent(controller, payload, encoder) {
|
|
83
|
+
emitText(controller, `${JSON.stringify(payload)}
|
|
84
|
+
`, encoder);
|
|
85
|
+
}
|
|
86
|
+
function createGeminiChatHandler(_options = {}) {
|
|
87
|
+
const requestParser = requestSchema;
|
|
88
|
+
return async function POST(request) {
|
|
89
|
+
const payload = await request.json().catch(() => null);
|
|
90
|
+
const parsed = requestParser.safeParse(payload);
|
|
91
|
+
if (!parsed.success) {
|
|
92
|
+
return Response.json(
|
|
93
|
+
{
|
|
94
|
+
error: "Invalid request payload.",
|
|
95
|
+
detail: parsed.error.flatten()
|
|
96
|
+
},
|
|
97
|
+
{ status: 400 }
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const stream = new ReadableStream({
|
|
101
|
+
start(controller) {
|
|
102
|
+
const encoder = new TextEncoder();
|
|
103
|
+
const abortController = new AbortController();
|
|
104
|
+
let closed = false;
|
|
105
|
+
const close = () => {
|
|
106
|
+
if (closed) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
closed = true;
|
|
110
|
+
try {
|
|
111
|
+
controller.close();
|
|
112
|
+
} catch {
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const onAbort = () => {
|
|
116
|
+
if (!abortController.signal.aborted) {
|
|
117
|
+
abortController.abort();
|
|
118
|
+
}
|
|
119
|
+
close();
|
|
120
|
+
};
|
|
121
|
+
if (request.signal.aborted) {
|
|
122
|
+
onAbort();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
request.signal.addEventListener("abort", onAbort);
|
|
126
|
+
const enqueueEvent = (payload2) => {
|
|
127
|
+
if (closed) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
emitEvent(controller, payload2, encoder);
|
|
131
|
+
};
|
|
132
|
+
const enqueueStdout = (text) => {
|
|
133
|
+
if (closed) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
emitText(controller, text, encoder);
|
|
137
|
+
};
|
|
138
|
+
(async () => {
|
|
139
|
+
const geminiApiKey = requiredEnv("GEMINI_API_KEY");
|
|
140
|
+
const sandboxSnapshotId = requiredEnv("SANDBOX_SNAPSHOT_ID");
|
|
141
|
+
const oidcToken = extractTokenFromRequest(request) ?? process.env.VERCEL_OIDC_TOKEN ?? "";
|
|
142
|
+
if (!oidcToken) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
"Planner authentication is required: set OIDC token in x-vercel-oidc-token or VERCEL_OIDC_TOKEN."
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const vercelProtectionBypass = process.env.VERCEL_PROTECTION_BYPASS?.trim() || void 0;
|
|
148
|
+
const giselleProtectionBypass = process.env.GISELLE_PROTECTION_PASSWORD?.trim() || void 0;
|
|
149
|
+
const relayUrl = process.env.BROWSER_TOOL_RELAY_URL?.trim() || new URL(request.url).origin;
|
|
150
|
+
const {
|
|
151
|
+
message,
|
|
152
|
+
session_id: sessionId,
|
|
153
|
+
sandbox_id: sandboxId,
|
|
154
|
+
relay_session_id: relaySessionId,
|
|
155
|
+
relay_token: relayToken
|
|
156
|
+
} = parsed.data;
|
|
157
|
+
const sandbox = sandboxId ? await Sandbox.get({ sandboxId }) : await Sandbox.create({
|
|
158
|
+
source: {
|
|
159
|
+
type: "snapshot",
|
|
160
|
+
snapshotId: sandboxSnapshotId
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
enqueueEvent({ type: "sandbox", sandbox_id: sandbox.sandboxId });
|
|
164
|
+
const mcpEnv = buildMcpEnv({
|
|
165
|
+
relayUrl,
|
|
166
|
+
relaySessionId,
|
|
167
|
+
relayToken,
|
|
168
|
+
oidcToken,
|
|
169
|
+
vercelProtectionBypass,
|
|
170
|
+
giselleProtectionBypass
|
|
171
|
+
});
|
|
172
|
+
await patchGeminiSettingsEnv(sandbox, mcpEnv);
|
|
173
|
+
const args = [
|
|
174
|
+
"--prompt",
|
|
175
|
+
message,
|
|
176
|
+
"--output-format",
|
|
177
|
+
"stream-json",
|
|
178
|
+
"--approval-mode",
|
|
179
|
+
"yolo"
|
|
180
|
+
];
|
|
181
|
+
if (sessionId) {
|
|
182
|
+
args.push("--resume", sessionId);
|
|
183
|
+
}
|
|
184
|
+
await sandbox.runCommand({
|
|
185
|
+
cmd: "gemini",
|
|
186
|
+
args,
|
|
187
|
+
env: {
|
|
188
|
+
GEMINI_API_KEY: geminiApiKey
|
|
189
|
+
},
|
|
190
|
+
stdout: new Writable({
|
|
191
|
+
write(chunk, _encoding, callback) {
|
|
192
|
+
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
193
|
+
enqueueStdout(text);
|
|
194
|
+
callback();
|
|
195
|
+
}
|
|
196
|
+
}),
|
|
197
|
+
stderr: new Writable({
|
|
198
|
+
write(chunk, _encoding, callback) {
|
|
199
|
+
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
200
|
+
enqueueEvent({ type: "stderr", content: text });
|
|
201
|
+
callback();
|
|
202
|
+
}
|
|
203
|
+
}),
|
|
204
|
+
signal: abortController.signal
|
|
205
|
+
});
|
|
206
|
+
})().catch((error) => {
|
|
207
|
+
if (abortController.signal.aborted) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
211
|
+
enqueueEvent({ type: "stderr", content: `[error] ${message}` });
|
|
212
|
+
}).finally(() => {
|
|
213
|
+
request.signal.removeEventListener("abort", onAbort);
|
|
214
|
+
close();
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
return new Response(stream, {
|
|
219
|
+
headers: {
|
|
220
|
+
"Content-Type": "application/x-ndjson; charset=utf-8",
|
|
221
|
+
"Cache-Control": "no-cache, no-transform"
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// src/relay-handler.ts
|
|
228
|
+
import {
|
|
229
|
+
relayRequestSchema,
|
|
230
|
+
relayResponseSchema as relayResponseSchema2
|
|
231
|
+
} from "@giselles-ai/browser-tool";
|
|
232
|
+
import { z as z2 } from "zod";
|
|
233
|
+
|
|
234
|
+
// src/relay-store.ts
|
|
2
235
|
import { randomUUID } from "crypto";
|
|
3
236
|
import {
|
|
4
|
-
|
|
237
|
+
relayResponseSchema
|
|
5
238
|
} from "@giselles-ai/browser-tool";
|
|
6
239
|
import Redis from "ioredis";
|
|
7
240
|
var DEFAULT_SESSION_TTL_MS = 10 * 60 * 1e3;
|
|
8
241
|
var DEFAULT_REQUEST_TTL_SEC = 60;
|
|
9
242
|
var DEFAULT_DISPATCH_TIMEOUT_MS = 20 * 1e3;
|
|
10
243
|
var BROWSER_PRESENCE_TTL_SEC = 90;
|
|
11
|
-
var
|
|
244
|
+
var RELAY_SSE_KEEPALIVE_INTERVAL_MS = 20 * 1e3;
|
|
12
245
|
var REDIS_URL_ENV_CANDIDATES = [
|
|
13
246
|
"REDIS_URL",
|
|
14
247
|
"REDIS_TLS_URL",
|
|
@@ -16,13 +249,13 @@ var REDIS_URL_ENV_CANDIDATES = [
|
|
|
16
249
|
"UPSTASH_REDIS_TLS_URL",
|
|
17
250
|
"UPSTASH_REDIS_URL"
|
|
18
251
|
];
|
|
19
|
-
var
|
|
252
|
+
var RELAY_SUBSCRIBER_REDIS_OPTIONS = {
|
|
20
253
|
enableReadyCheck: false,
|
|
21
254
|
autoResubscribe: false,
|
|
22
255
|
autoResendUnfulfilledCommands: false,
|
|
23
256
|
maxRetriesPerRequest: 2
|
|
24
257
|
};
|
|
25
|
-
var
|
|
258
|
+
var RelayStoreError = class extends Error {
|
|
26
259
|
code;
|
|
27
260
|
status;
|
|
28
261
|
constructor(code, message, status) {
|
|
@@ -31,22 +264,22 @@ var BridgeBrokerError = class extends Error {
|
|
|
31
264
|
this.status = status;
|
|
32
265
|
}
|
|
33
266
|
};
|
|
34
|
-
function
|
|
267
|
+
function createRelayError(code, message) {
|
|
35
268
|
switch (code) {
|
|
36
269
|
case "UNAUTHORIZED":
|
|
37
|
-
return new
|
|
270
|
+
return new RelayStoreError(code, message, 401);
|
|
38
271
|
case "NO_BROWSER":
|
|
39
|
-
return new
|
|
272
|
+
return new RelayStoreError(code, message, 409);
|
|
40
273
|
case "TIMEOUT":
|
|
41
|
-
return new
|
|
274
|
+
return new RelayStoreError(code, message, 408);
|
|
42
275
|
case "INVALID_RESPONSE":
|
|
43
|
-
return new
|
|
276
|
+
return new RelayStoreError(code, message, 422);
|
|
44
277
|
case "NOT_FOUND":
|
|
45
|
-
return new
|
|
278
|
+
return new RelayStoreError(code, message, 404);
|
|
46
279
|
case "INTERNAL":
|
|
47
|
-
return new
|
|
280
|
+
return new RelayStoreError(code, message, 500);
|
|
48
281
|
default:
|
|
49
|
-
return new
|
|
282
|
+
return new RelayStoreError("INTERNAL", message, 500);
|
|
50
283
|
}
|
|
51
284
|
}
|
|
52
285
|
function resolveRedisUrl() {
|
|
@@ -56,39 +289,39 @@ function resolveRedisUrl() {
|
|
|
56
289
|
return value;
|
|
57
290
|
}
|
|
58
291
|
}
|
|
59
|
-
throw
|
|
292
|
+
throw createRelayError(
|
|
60
293
|
"INTERNAL",
|
|
61
294
|
`Missing Redis URL. Set one of: ${REDIS_URL_ENV_CANDIDATES.join(", ")}`
|
|
62
295
|
);
|
|
63
296
|
}
|
|
64
297
|
function getRedisClient() {
|
|
65
|
-
if (!globalThis.
|
|
66
|
-
globalThis.
|
|
298
|
+
if (!globalThis.__browserToolRelayRedis) {
|
|
299
|
+
globalThis.__browserToolRelayRedis = new Redis(resolveRedisUrl(), {
|
|
67
300
|
maxRetriesPerRequest: 2
|
|
68
301
|
});
|
|
69
302
|
}
|
|
70
|
-
return globalThis.
|
|
303
|
+
return globalThis.__browserToolRelayRedis;
|
|
71
304
|
}
|
|
72
|
-
function
|
|
73
|
-
return getRedisClient().duplicate(
|
|
305
|
+
function createRelaySubscriber() {
|
|
306
|
+
return getRedisClient().duplicate(RELAY_SUBSCRIBER_REDIS_OPTIONS);
|
|
74
307
|
}
|
|
75
308
|
function sessionKey(sessionId) {
|
|
76
|
-
return `
|
|
309
|
+
return `relay:session:${sessionId}`;
|
|
77
310
|
}
|
|
78
311
|
function browserPresenceKey(sessionId) {
|
|
79
|
-
return `
|
|
312
|
+
return `relay:browser:${sessionId}`;
|
|
80
313
|
}
|
|
81
314
|
function requestTypeKey(sessionId, requestId) {
|
|
82
|
-
return `
|
|
315
|
+
return `relay:req:${sessionId}:${requestId}:type`;
|
|
83
316
|
}
|
|
84
317
|
function responseKey(sessionId, requestId) {
|
|
85
|
-
return `
|
|
318
|
+
return `relay:resp:${sessionId}:${requestId}`;
|
|
86
319
|
}
|
|
87
|
-
function
|
|
88
|
-
return `
|
|
320
|
+
function relayRequestChannel(sessionId) {
|
|
321
|
+
return `relay:${sessionId}:request`;
|
|
89
322
|
}
|
|
90
|
-
function
|
|
91
|
-
return `
|
|
323
|
+
function relayResponseChannel(sessionId, requestId) {
|
|
324
|
+
return `relay:${sessionId}:response:${requestId}`;
|
|
92
325
|
}
|
|
93
326
|
function sessionExpiryTimestamp() {
|
|
94
327
|
return Date.now() + DEFAULT_SESSION_TTL_MS;
|
|
@@ -98,15 +331,15 @@ function parseSessionRecord(raw) {
|
|
|
98
331
|
try {
|
|
99
332
|
parsed = JSON.parse(raw);
|
|
100
333
|
} catch {
|
|
101
|
-
throw
|
|
334
|
+
throw createRelayError(
|
|
102
335
|
"INTERNAL",
|
|
103
|
-
"
|
|
336
|
+
"Relay session payload in Redis is malformed."
|
|
104
337
|
);
|
|
105
338
|
}
|
|
106
339
|
if (!parsed || typeof parsed !== "object" || !("token" in parsed) || !("expiresAt" in parsed) || typeof parsed.token !== "string" || typeof parsed.expiresAt !== "number") {
|
|
107
|
-
throw
|
|
340
|
+
throw createRelayError(
|
|
108
341
|
"INTERNAL",
|
|
109
|
-
"
|
|
342
|
+
"Relay session payload in Redis is invalid."
|
|
110
343
|
);
|
|
111
344
|
}
|
|
112
345
|
return {
|
|
@@ -118,26 +351,26 @@ function toRequestType(value) {
|
|
|
118
351
|
if (value === "snapshot_request" || value === "execute_request") {
|
|
119
352
|
return value;
|
|
120
353
|
}
|
|
121
|
-
throw
|
|
354
|
+
throw createRelayError(
|
|
122
355
|
"INTERNAL",
|
|
123
356
|
`Stored request type is invalid: ${value}`
|
|
124
357
|
);
|
|
125
358
|
}
|
|
126
|
-
function
|
|
359
|
+
function parseStoredRelayResponse(raw) {
|
|
127
360
|
let decoded = null;
|
|
128
361
|
try {
|
|
129
362
|
decoded = JSON.parse(raw);
|
|
130
363
|
} catch {
|
|
131
|
-
throw
|
|
364
|
+
throw createRelayError(
|
|
132
365
|
"INVALID_RESPONSE",
|
|
133
|
-
"
|
|
366
|
+
"Relay response payload in Redis is malformed."
|
|
134
367
|
);
|
|
135
368
|
}
|
|
136
|
-
const parsed =
|
|
369
|
+
const parsed = relayResponseSchema.safeParse(decoded);
|
|
137
370
|
if (!parsed.success) {
|
|
138
|
-
throw
|
|
371
|
+
throw createRelayError(
|
|
139
372
|
"INVALID_RESPONSE",
|
|
140
|
-
"
|
|
373
|
+
"Relay response payload in Redis is invalid."
|
|
141
374
|
);
|
|
142
375
|
}
|
|
143
376
|
return parsed.data;
|
|
@@ -157,16 +390,16 @@ async function getAuthorizedSession(sessionId, token) {
|
|
|
157
390
|
const redis = getRedisClient();
|
|
158
391
|
const raw = await redis.get(sessionKey(sessionId));
|
|
159
392
|
if (!raw) {
|
|
160
|
-
throw
|
|
393
|
+
throw createRelayError(
|
|
161
394
|
"UNAUTHORIZED",
|
|
162
|
-
"Invalid
|
|
395
|
+
"Invalid relay session credentials."
|
|
163
396
|
);
|
|
164
397
|
}
|
|
165
398
|
const session = parseSessionRecord(raw);
|
|
166
399
|
if (session.token !== token) {
|
|
167
|
-
throw
|
|
400
|
+
throw createRelayError(
|
|
168
401
|
"UNAUTHORIZED",
|
|
169
|
-
"Invalid
|
|
402
|
+
"Invalid relay session credentials."
|
|
170
403
|
);
|
|
171
404
|
}
|
|
172
405
|
const expiresAt = await touchSession(sessionId, token);
|
|
@@ -177,13 +410,13 @@ async function getAuthorizedSession(sessionId, token) {
|
|
|
177
410
|
}
|
|
178
411
|
function validateResponseType(requestType, responseType) {
|
|
179
412
|
if (requestType === "snapshot_request" && responseType !== "snapshot_response") {
|
|
180
|
-
throw
|
|
413
|
+
throw createRelayError(
|
|
181
414
|
"INVALID_RESPONSE",
|
|
182
415
|
`Expected snapshot_response, but received ${responseType}.`
|
|
183
416
|
);
|
|
184
417
|
}
|
|
185
418
|
if (requestType === "execute_request" && responseType !== "execute_response") {
|
|
186
|
-
throw
|
|
419
|
+
throw createRelayError(
|
|
187
420
|
"INVALID_RESPONSE",
|
|
188
421
|
`Expected execute_response, but received ${responseType}.`
|
|
189
422
|
);
|
|
@@ -193,13 +426,13 @@ async function ensureBrowserConnected(sessionId) {
|
|
|
193
426
|
const redis = getRedisClient();
|
|
194
427
|
const isConnected = await redis.exists(browserPresenceKey(sessionId));
|
|
195
428
|
if (isConnected === 0) {
|
|
196
|
-
throw
|
|
429
|
+
throw createRelayError(
|
|
197
430
|
"NO_BROWSER",
|
|
198
|
-
"No browser client is connected to this
|
|
431
|
+
"No browser client is connected to this relay session."
|
|
199
432
|
);
|
|
200
433
|
}
|
|
201
434
|
}
|
|
202
|
-
async function
|
|
435
|
+
async function waitForRelayResponseSignal(input) {
|
|
203
436
|
await new Promise((resolve, reject) => {
|
|
204
437
|
let settled = false;
|
|
205
438
|
const onMessage = (channel) => {
|
|
@@ -218,7 +451,7 @@ async function waitForBridgeResponseSignal(input) {
|
|
|
218
451
|
cleanup();
|
|
219
452
|
const message = error instanceof Error ? error.message : "Unknown Redis subscriber error.";
|
|
220
453
|
reject(
|
|
221
|
-
|
|
454
|
+
createRelayError(
|
|
222
455
|
"INTERNAL",
|
|
223
456
|
`Redis subscriber failed while waiting for response. ${message}`
|
|
224
457
|
)
|
|
@@ -231,9 +464,9 @@ async function waitForBridgeResponseSignal(input) {
|
|
|
231
464
|
settled = true;
|
|
232
465
|
cleanup();
|
|
233
466
|
reject(
|
|
234
|
-
|
|
467
|
+
createRelayError(
|
|
235
468
|
"TIMEOUT",
|
|
236
|
-
"Timed out waiting for browser
|
|
469
|
+
"Timed out waiting for browser relay response."
|
|
237
470
|
)
|
|
238
471
|
);
|
|
239
472
|
};
|
|
@@ -251,21 +484,21 @@ async function waitForBridgeResponseSignal(input) {
|
|
|
251
484
|
}
|
|
252
485
|
settled = true;
|
|
253
486
|
cleanup();
|
|
254
|
-
if (error instanceof
|
|
487
|
+
if (error instanceof RelayStoreError) {
|
|
255
488
|
reject(error);
|
|
256
489
|
return;
|
|
257
490
|
}
|
|
258
491
|
const message = error instanceof Error ? error.message : "Unknown Redis publish error.";
|
|
259
492
|
reject(
|
|
260
|
-
|
|
493
|
+
createRelayError(
|
|
261
494
|
"INTERNAL",
|
|
262
|
-
`Failed to publish
|
|
495
|
+
`Failed to publish relay request. ${message}`
|
|
263
496
|
)
|
|
264
497
|
);
|
|
265
498
|
});
|
|
266
499
|
});
|
|
267
500
|
}
|
|
268
|
-
async function
|
|
501
|
+
async function storeRelayResponse(input) {
|
|
269
502
|
const redis = getRedisClient();
|
|
270
503
|
await redis.multi().set(
|
|
271
504
|
responseKey(input.sessionId, input.requestId),
|
|
@@ -273,11 +506,11 @@ async function storeBridgeResponse(input) {
|
|
|
273
506
|
"EX",
|
|
274
507
|
DEFAULT_REQUEST_TTL_SEC
|
|
275
508
|
).del(requestTypeKey(input.sessionId, input.requestId)).publish(
|
|
276
|
-
|
|
509
|
+
relayResponseChannel(input.sessionId, input.requestId),
|
|
277
510
|
input.requestId
|
|
278
511
|
).exec();
|
|
279
512
|
}
|
|
280
|
-
async function
|
|
513
|
+
async function createRelaySession() {
|
|
281
514
|
const sessionId = randomUUID();
|
|
282
515
|
const token = `${randomUUID()}-${randomUUID()}`;
|
|
283
516
|
const expiresAt = sessionExpiryTimestamp();
|
|
@@ -297,10 +530,10 @@ async function createBridgeSession() {
|
|
|
297
530
|
expiresAt
|
|
298
531
|
};
|
|
299
532
|
}
|
|
300
|
-
async function
|
|
533
|
+
async function assertRelaySession(sessionId, token) {
|
|
301
534
|
await getAuthorizedSession(sessionId, token);
|
|
302
535
|
}
|
|
303
|
-
async function
|
|
536
|
+
async function markBrowserConnected(sessionId, token) {
|
|
304
537
|
await getAuthorizedSession(sessionId, token);
|
|
305
538
|
const redis = getRedisClient();
|
|
306
539
|
await redis.set(
|
|
@@ -310,7 +543,7 @@ async function markBridgeBrowserConnected(sessionId, token) {
|
|
|
310
543
|
BROWSER_PRESENCE_TTL_SEC
|
|
311
544
|
);
|
|
312
545
|
}
|
|
313
|
-
async function
|
|
546
|
+
async function touchBrowserConnected(sessionId) {
|
|
314
547
|
const redis = getRedisClient();
|
|
315
548
|
await redis.set(
|
|
316
549
|
browserPresenceKey(sessionId),
|
|
@@ -319,7 +552,7 @@ async function touchBridgeBrowserConnected(sessionId) {
|
|
|
319
552
|
BROWSER_PRESENCE_TTL_SEC
|
|
320
553
|
);
|
|
321
554
|
}
|
|
322
|
-
async function
|
|
555
|
+
async function dispatchRelayRequest(input) {
|
|
323
556
|
await getAuthorizedSession(input.sessionId, input.token);
|
|
324
557
|
await ensureBrowserConnected(input.sessionId);
|
|
325
558
|
const redis = getRedisClient();
|
|
@@ -327,11 +560,8 @@ async function dispatchBridgeRequest(input) {
|
|
|
327
560
|
const requestId = input.request.requestId;
|
|
328
561
|
const requestTypeStateKey = requestTypeKey(input.sessionId, requestId);
|
|
329
562
|
const storedResponseKey = responseKey(input.sessionId, requestId);
|
|
330
|
-
const responseEventChannel =
|
|
331
|
-
|
|
332
|
-
requestId
|
|
333
|
-
);
|
|
334
|
-
const subscriber = createBridgeSubscriber();
|
|
563
|
+
const responseEventChannel = relayResponseChannel(input.sessionId, requestId);
|
|
564
|
+
const subscriber = createRelaySubscriber();
|
|
335
565
|
const setPending = await redis.set(
|
|
336
566
|
requestTypeStateKey,
|
|
337
567
|
input.request.type,
|
|
@@ -340,7 +570,7 @@ async function dispatchBridgeRequest(input) {
|
|
|
340
570
|
"NX"
|
|
341
571
|
);
|
|
342
572
|
if (setPending !== "OK") {
|
|
343
|
-
throw
|
|
573
|
+
throw createRelayError(
|
|
344
574
|
"INVALID_RESPONSE",
|
|
345
575
|
`Request id is already pending: ${requestId}`
|
|
346
576
|
);
|
|
@@ -348,27 +578,27 @@ async function dispatchBridgeRequest(input) {
|
|
|
348
578
|
try {
|
|
349
579
|
await redis.del(storedResponseKey);
|
|
350
580
|
await subscriber.subscribe(responseEventChannel);
|
|
351
|
-
await
|
|
581
|
+
await waitForRelayResponseSignal({
|
|
352
582
|
subscriber,
|
|
353
583
|
channel: responseEventChannel,
|
|
354
584
|
timeoutMs,
|
|
355
585
|
trigger: async () => {
|
|
356
586
|
await redis.publish(
|
|
357
|
-
|
|
587
|
+
relayRequestChannel(input.sessionId),
|
|
358
588
|
JSON.stringify(input.request)
|
|
359
589
|
);
|
|
360
590
|
}
|
|
361
591
|
});
|
|
362
592
|
const storedResponse = await redis.get(storedResponseKey);
|
|
363
593
|
if (!storedResponse) {
|
|
364
|
-
throw
|
|
594
|
+
throw createRelayError(
|
|
365
595
|
"TIMEOUT",
|
|
366
|
-
"
|
|
596
|
+
"Relay response notification was received without payload."
|
|
367
597
|
);
|
|
368
598
|
}
|
|
369
|
-
const parsedResponse =
|
|
599
|
+
const parsedResponse = parseStoredRelayResponse(storedResponse);
|
|
370
600
|
if (parsedResponse.type === "error_response") {
|
|
371
|
-
throw
|
|
601
|
+
throw createRelayError("INVALID_RESPONSE", parsedResponse.message);
|
|
372
602
|
}
|
|
373
603
|
validateResponseType(input.request.type, parsedResponse.type);
|
|
374
604
|
return parsedResponse;
|
|
@@ -383,21 +613,21 @@ async function dispatchBridgeRequest(input) {
|
|
|
383
613
|
});
|
|
384
614
|
}
|
|
385
615
|
}
|
|
386
|
-
async function
|
|
616
|
+
async function resolveRelayResponse(input) {
|
|
387
617
|
await getAuthorizedSession(input.sessionId, input.token);
|
|
388
618
|
const redis = getRedisClient();
|
|
389
619
|
const requestId = input.response.requestId;
|
|
390
620
|
const requestTypeStateKey = requestTypeKey(input.sessionId, requestId);
|
|
391
621
|
const expectedRequestTypeRaw = await redis.get(requestTypeStateKey);
|
|
392
622
|
if (!expectedRequestTypeRaw) {
|
|
393
|
-
throw
|
|
623
|
+
throw createRelayError(
|
|
394
624
|
"NOT_FOUND",
|
|
395
625
|
`Pending request was not found: ${requestId}`
|
|
396
626
|
);
|
|
397
627
|
}
|
|
398
628
|
const expectedRequestType = toRequestType(expectedRequestTypeRaw);
|
|
399
629
|
if (input.response.type === "error_response") {
|
|
400
|
-
await
|
|
630
|
+
await storeRelayResponse({
|
|
401
631
|
sessionId: input.sessionId,
|
|
402
632
|
requestId,
|
|
403
633
|
response: input.response
|
|
@@ -407,63 +637,58 @@ async function resolveBridgeResponse(input) {
|
|
|
407
637
|
try {
|
|
408
638
|
validateResponseType(expectedRequestType, input.response.type);
|
|
409
639
|
} catch (error) {
|
|
410
|
-
const
|
|
640
|
+
const relayError = error instanceof RelayStoreError ? error : createRelayError(
|
|
411
641
|
"INVALID_RESPONSE",
|
|
412
|
-
"Unexpected
|
|
642
|
+
"Unexpected relay response type."
|
|
413
643
|
);
|
|
414
|
-
await
|
|
644
|
+
await storeRelayResponse({
|
|
415
645
|
sessionId: input.sessionId,
|
|
416
646
|
requestId,
|
|
417
647
|
response: {
|
|
418
648
|
type: "error_response",
|
|
419
649
|
requestId,
|
|
420
|
-
message:
|
|
650
|
+
message: relayError.message
|
|
421
651
|
}
|
|
422
652
|
});
|
|
423
|
-
throw
|
|
653
|
+
throw relayError;
|
|
424
654
|
}
|
|
425
|
-
await
|
|
655
|
+
await storeRelayResponse({
|
|
426
656
|
sessionId: input.sessionId,
|
|
427
657
|
requestId,
|
|
428
658
|
response: input.response
|
|
429
659
|
});
|
|
430
660
|
}
|
|
431
|
-
function
|
|
432
|
-
if (error instanceof
|
|
661
|
+
function toRelayError(error) {
|
|
662
|
+
if (error instanceof RelayStoreError) {
|
|
433
663
|
return error;
|
|
434
664
|
}
|
|
435
|
-
const message = error instanceof Error ? error.message : "Unexpected
|
|
436
|
-
return
|
|
665
|
+
const message = error instanceof Error ? error.message : "Unexpected relay failure.";
|
|
666
|
+
return createRelayError("INTERNAL", message);
|
|
437
667
|
}
|
|
438
668
|
|
|
439
|
-
// src/
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
type: z.literal("bridge.dispatch"),
|
|
448
|
-
sessionId: z.string().min(1),
|
|
449
|
-
token: z.string().min(1),
|
|
450
|
-
request: bridgeRequestSchema,
|
|
451
|
-
timeoutMs: z.number().int().positive().max(55e3).optional()
|
|
669
|
+
// src/relay-handler.ts
|
|
670
|
+
var LOG_PREFIX = "[relay-handler]";
|
|
671
|
+
var dispatchSchema = z2.object({
|
|
672
|
+
type: z2.literal("relay.dispatch"),
|
|
673
|
+
sessionId: z2.string().min(1),
|
|
674
|
+
token: z2.string().min(1),
|
|
675
|
+
request: relayRequestSchema,
|
|
676
|
+
timeoutMs: z2.number().int().positive().max(55e3).optional()
|
|
452
677
|
});
|
|
453
|
-
var respondSchema =
|
|
454
|
-
type:
|
|
455
|
-
sessionId:
|
|
456
|
-
token:
|
|
457
|
-
response:
|
|
678
|
+
var respondSchema = z2.object({
|
|
679
|
+
type: z2.literal("relay.respond"),
|
|
680
|
+
sessionId: z2.string().min(1),
|
|
681
|
+
token: z2.string().min(1),
|
|
682
|
+
response: relayResponseSchema2
|
|
458
683
|
});
|
|
459
|
-
var postBodySchema =
|
|
684
|
+
var postBodySchema = z2.discriminatedUnion("type", [
|
|
460
685
|
dispatchSchema,
|
|
461
686
|
respondSchema
|
|
462
687
|
]);
|
|
463
688
|
function createSafeError(code, message, status) {
|
|
464
689
|
return Response.json({ ok: false, errorCode: code, message }, { status });
|
|
465
690
|
}
|
|
466
|
-
function
|
|
691
|
+
function createRelayEventsRoute(request) {
|
|
467
692
|
const url = new URL(request.url);
|
|
468
693
|
const sessionId = url.searchParams.get("sessionId") ?? "";
|
|
469
694
|
const token = url.searchParams.get("token") ?? "";
|
|
@@ -480,12 +705,12 @@ function createBridgeEventsRoute(request) {
|
|
|
480
705
|
}
|
|
481
706
|
let cleanup = null;
|
|
482
707
|
const encoder = new TextEncoder();
|
|
483
|
-
return
|
|
708
|
+
return assertRelaySession(sessionId, token).then(() => {
|
|
484
709
|
console.info(`${LOG_PREFIX} sse.connect`, { sessionId });
|
|
485
|
-
const requestChannel =
|
|
710
|
+
const requestChannel = relayRequestChannel(sessionId);
|
|
486
711
|
const stream = new ReadableStream({
|
|
487
712
|
start(controller) {
|
|
488
|
-
const subscriber =
|
|
713
|
+
const subscriber = createRelaySubscriber();
|
|
489
714
|
let keepaliveId = null;
|
|
490
715
|
let closed = false;
|
|
491
716
|
let nextEventId = 0;
|
|
@@ -567,20 +792,18 @@ data: ${rawJson}
|
|
|
567
792
|
void (async () => {
|
|
568
793
|
try {
|
|
569
794
|
await subscriber.subscribe(requestChannel);
|
|
570
|
-
await
|
|
795
|
+
await markBrowserConnected(sessionId, token);
|
|
571
796
|
console.info(`${LOG_PREFIX} sse.ready`, { sessionId });
|
|
572
797
|
sendSseData({ type: "ready", sessionId });
|
|
573
798
|
keepaliveId = setInterval(() => {
|
|
574
|
-
void
|
|
575
|
-
() => void 0
|
|
576
|
-
);
|
|
799
|
+
void touchBrowserConnected(sessionId).catch(() => void 0);
|
|
577
800
|
try {
|
|
578
801
|
sendSseComment("keepalive");
|
|
579
802
|
} catch {
|
|
580
803
|
void cleanup?.();
|
|
581
804
|
closeController();
|
|
582
805
|
}
|
|
583
|
-
},
|
|
806
|
+
}, RELAY_SSE_KEEPALIVE_INTERVAL_MS);
|
|
584
807
|
} catch (error) {
|
|
585
808
|
if (closed) {
|
|
586
809
|
return;
|
|
@@ -608,25 +831,25 @@ data: ${rawJson}
|
|
|
608
831
|
sessionId,
|
|
609
832
|
error: error instanceof Error ? error.message : String(error)
|
|
610
833
|
});
|
|
611
|
-
const
|
|
834
|
+
const relayError = toRelayError(error);
|
|
612
835
|
return Response.json(
|
|
613
836
|
{
|
|
614
|
-
errorCode:
|
|
615
|
-
message:
|
|
837
|
+
errorCode: relayError.code,
|
|
838
|
+
message: relayError.message
|
|
616
839
|
},
|
|
617
|
-
{ status:
|
|
840
|
+
{ status: relayError.status }
|
|
618
841
|
);
|
|
619
842
|
});
|
|
620
843
|
}
|
|
621
|
-
async function
|
|
844
|
+
async function createRelayPostRoute(request) {
|
|
622
845
|
const payload = await request.json().catch(() => null);
|
|
623
846
|
const parsed = postBodySchema.safeParse(payload);
|
|
624
847
|
if (!parsed.success) {
|
|
625
848
|
return createSafeError("INVALID_RESPONSE", "Invalid request payload.", 400);
|
|
626
849
|
}
|
|
627
|
-
if (parsed.data.type === "
|
|
850
|
+
if (parsed.data.type === "relay.dispatch") {
|
|
628
851
|
try {
|
|
629
|
-
const response = await
|
|
852
|
+
const response = await dispatchRelayRequest({
|
|
630
853
|
sessionId: parsed.data.sessionId,
|
|
631
854
|
token: parsed.data.token,
|
|
632
855
|
request: parsed.data.request,
|
|
@@ -634,273 +857,47 @@ async function createBridgePostRoute(request) {
|
|
|
634
857
|
});
|
|
635
858
|
return Response.json({ ok: true, response });
|
|
636
859
|
} catch (error) {
|
|
637
|
-
const
|
|
860
|
+
const relayError = toRelayError(error);
|
|
638
861
|
return createSafeError(
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
862
|
+
relayError.code,
|
|
863
|
+
relayError.message,
|
|
864
|
+
relayError.status
|
|
642
865
|
);
|
|
643
866
|
}
|
|
644
867
|
}
|
|
645
868
|
try {
|
|
646
|
-
await
|
|
869
|
+
await resolveRelayResponse({
|
|
647
870
|
sessionId: parsed.data.sessionId,
|
|
648
871
|
token: parsed.data.token,
|
|
649
872
|
response: parsed.data.response
|
|
650
873
|
});
|
|
651
874
|
return Response.json({ ok: true });
|
|
652
875
|
} catch (error) {
|
|
653
|
-
const
|
|
876
|
+
const relayError = toRelayError(error);
|
|
654
877
|
return createSafeError(
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
878
|
+
relayError.code,
|
|
879
|
+
relayError.message,
|
|
880
|
+
relayError.status
|
|
658
881
|
);
|
|
659
882
|
}
|
|
660
883
|
}
|
|
661
|
-
function
|
|
884
|
+
function createRelayHandler() {
|
|
662
885
|
return {
|
|
663
|
-
GET: async (request) =>
|
|
664
|
-
POST:
|
|
665
|
-
};
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
// src/chat-handler.ts
|
|
669
|
-
import { Writable } from "stream";
|
|
670
|
-
import { Sandbox } from "@vercel/sandbox";
|
|
671
|
-
import { z as z2 } from "zod";
|
|
672
|
-
var GEMINI_SETTINGS_PATH = "/home/vercel-sandbox/.gemini/settings.json";
|
|
673
|
-
var requestSchema = z2.object({
|
|
674
|
-
message: z2.string().min(1),
|
|
675
|
-
session_id: z2.string().min(1).optional(),
|
|
676
|
-
sandbox_id: z2.string().min(1).optional(),
|
|
677
|
-
bridge_session_id: z2.string().min(1),
|
|
678
|
-
bridge_token: z2.string().min(1)
|
|
679
|
-
});
|
|
680
|
-
function requiredEnv(name) {
|
|
681
|
-
const value = process.env[name]?.trim();
|
|
682
|
-
if (!value) {
|
|
683
|
-
throw new Error(`Missing required environment variable: ${name}`);
|
|
684
|
-
}
|
|
685
|
-
return value;
|
|
686
|
-
}
|
|
687
|
-
function extractTokenFromRequest(request) {
|
|
688
|
-
const oidcToken = request.headers.get("x-vercel-oidc-token")?.trim();
|
|
689
|
-
if (oidcToken) {
|
|
690
|
-
return oidcToken;
|
|
691
|
-
}
|
|
692
|
-
const authorization = request.headers.get("authorization")?.trim();
|
|
693
|
-
if (!authorization) {
|
|
694
|
-
return void 0;
|
|
695
|
-
}
|
|
696
|
-
if (/^bearer\s+/i.test(authorization)) {
|
|
697
|
-
return authorization.replace(/^bearer\s+/i, "").trim();
|
|
698
|
-
}
|
|
699
|
-
return authorization;
|
|
700
|
-
}
|
|
701
|
-
function buildMcpEnv(input) {
|
|
702
|
-
const env = {
|
|
703
|
-
BROWSER_TOOL_BRIDGE_BASE_URL: input.bridgeBaseUrl,
|
|
704
|
-
BROWSER_TOOL_BRIDGE_SESSION_ID: input.bridgeSessionId,
|
|
705
|
-
BROWSER_TOOL_BRIDGE_TOKEN: input.bridgeToken
|
|
706
|
-
};
|
|
707
|
-
if (input.oidcToken) {
|
|
708
|
-
env.VERCEL_OIDC_TOKEN = input.oidcToken;
|
|
709
|
-
}
|
|
710
|
-
if (input.vercelProtectionBypass?.trim()) {
|
|
711
|
-
env.VERCEL_PROTECTION_BYPASS = input.vercelProtectionBypass.trim();
|
|
712
|
-
}
|
|
713
|
-
if (input.giselleProtectionBypass?.trim()) {
|
|
714
|
-
env.GISELLE_PROTECTION_BYPASS = input.giselleProtectionBypass.trim();
|
|
715
|
-
}
|
|
716
|
-
return env;
|
|
717
|
-
}
|
|
718
|
-
async function patchGeminiSettingsEnv(sandbox, mcpEnv) {
|
|
719
|
-
const buffer = await sandbox.readFileToBuffer({
|
|
720
|
-
path: GEMINI_SETTINGS_PATH
|
|
721
|
-
});
|
|
722
|
-
if (!buffer) {
|
|
723
|
-
throw new Error(
|
|
724
|
-
`Gemini settings not found in sandbox at ${GEMINI_SETTINGS_PATH}. Ensure the snapshot contains a pre-configured settings.json.`
|
|
725
|
-
);
|
|
726
|
-
}
|
|
727
|
-
const settings = JSON.parse(new TextDecoder().decode(buffer));
|
|
728
|
-
if (settings.mcpServers) {
|
|
729
|
-
settings.mcpServers = Object.fromEntries(
|
|
730
|
-
Object.entries(settings.mcpServers).map(([key, server]) => [
|
|
731
|
-
key,
|
|
732
|
-
{ ...server, env: { ...server.env, ...mcpEnv } }
|
|
733
|
-
])
|
|
734
|
-
);
|
|
735
|
-
}
|
|
736
|
-
await sandbox.writeFiles([
|
|
737
|
-
{
|
|
738
|
-
path: GEMINI_SETTINGS_PATH,
|
|
739
|
-
content: Buffer.from(JSON.stringify(settings, null, 2))
|
|
740
|
-
}
|
|
741
|
-
]);
|
|
742
|
-
}
|
|
743
|
-
function emitText(controller, text, encoder) {
|
|
744
|
-
if (text.length === 0) {
|
|
745
|
-
return;
|
|
746
|
-
}
|
|
747
|
-
controller.enqueue(encoder.encode(text));
|
|
748
|
-
}
|
|
749
|
-
function emitEvent(controller, payload, encoder) {
|
|
750
|
-
emitText(controller, `${JSON.stringify(payload)}
|
|
751
|
-
`, encoder);
|
|
752
|
-
}
|
|
753
|
-
function createGeminiChatHandler(_options = {}) {
|
|
754
|
-
const requestParser = requestSchema;
|
|
755
|
-
return async function POST(request) {
|
|
756
|
-
const payload = await request.json().catch(() => null);
|
|
757
|
-
const parsed = requestParser.safeParse(payload);
|
|
758
|
-
if (!parsed.success) {
|
|
759
|
-
return Response.json(
|
|
760
|
-
{
|
|
761
|
-
error: "Invalid request payload.",
|
|
762
|
-
detail: parsed.error.flatten()
|
|
763
|
-
},
|
|
764
|
-
{ status: 400 }
|
|
765
|
-
);
|
|
766
|
-
}
|
|
767
|
-
const stream = new ReadableStream({
|
|
768
|
-
start(controller) {
|
|
769
|
-
const encoder = new TextEncoder();
|
|
770
|
-
const abortController = new AbortController();
|
|
771
|
-
let closed = false;
|
|
772
|
-
const close = () => {
|
|
773
|
-
if (closed) {
|
|
774
|
-
return;
|
|
775
|
-
}
|
|
776
|
-
closed = true;
|
|
777
|
-
try {
|
|
778
|
-
controller.close();
|
|
779
|
-
} catch {
|
|
780
|
-
}
|
|
781
|
-
};
|
|
782
|
-
const onAbort = () => {
|
|
783
|
-
if (!abortController.signal.aborted) {
|
|
784
|
-
abortController.abort();
|
|
785
|
-
}
|
|
786
|
-
close();
|
|
787
|
-
};
|
|
788
|
-
if (request.signal.aborted) {
|
|
789
|
-
onAbort();
|
|
790
|
-
return;
|
|
791
|
-
}
|
|
792
|
-
request.signal.addEventListener("abort", onAbort);
|
|
793
|
-
const enqueueEvent = (payload2) => {
|
|
794
|
-
if (closed) {
|
|
795
|
-
return;
|
|
796
|
-
}
|
|
797
|
-
emitEvent(controller, payload2, encoder);
|
|
798
|
-
};
|
|
799
|
-
const enqueueStdout = (text) => {
|
|
800
|
-
if (closed) {
|
|
801
|
-
return;
|
|
802
|
-
}
|
|
803
|
-
emitText(controller, text, encoder);
|
|
804
|
-
};
|
|
805
|
-
(async () => {
|
|
806
|
-
const geminiApiKey = requiredEnv("GEMINI_API_KEY");
|
|
807
|
-
const sandboxSnapshotId = requiredEnv("SANDBOX_SNAPSHOT_ID");
|
|
808
|
-
const oidcToken = extractTokenFromRequest(request) ?? process.env.VERCEL_OIDC_TOKEN ?? "";
|
|
809
|
-
if (!oidcToken) {
|
|
810
|
-
throw new Error(
|
|
811
|
-
"Planner authentication is required: set OIDC token in x-vercel-oidc-token or VERCEL_OIDC_TOKEN."
|
|
812
|
-
);
|
|
813
|
-
}
|
|
814
|
-
const vercelProtectionBypass = process.env.VERCEL_PROTECTION_BYPASS?.trim() || void 0;
|
|
815
|
-
const giselleProtectionBypass = process.env.GISELLE_PROTECTION_PASSWORD?.trim() || void 0;
|
|
816
|
-
const bridgeBaseUrl = process.env.BROWSER_TOOL_BRIDGE_BASE_URL?.trim() || new URL(request.url).origin;
|
|
817
|
-
const {
|
|
818
|
-
message,
|
|
819
|
-
session_id: sessionId,
|
|
820
|
-
sandbox_id: sandboxId,
|
|
821
|
-
bridge_session_id: bridgeSessionId,
|
|
822
|
-
bridge_token: bridgeToken
|
|
823
|
-
} = parsed.data;
|
|
824
|
-
const sandbox = sandboxId ? await Sandbox.get({ sandboxId }) : await Sandbox.create({
|
|
825
|
-
source: {
|
|
826
|
-
type: "snapshot",
|
|
827
|
-
snapshotId: sandboxSnapshotId
|
|
828
|
-
}
|
|
829
|
-
});
|
|
830
|
-
enqueueEvent({ type: "sandbox", sandbox_id: sandbox.sandboxId });
|
|
831
|
-
const mcpEnv = buildMcpEnv({
|
|
832
|
-
bridgeBaseUrl,
|
|
833
|
-
bridgeSessionId,
|
|
834
|
-
bridgeToken,
|
|
835
|
-
oidcToken,
|
|
836
|
-
vercelProtectionBypass,
|
|
837
|
-
giselleProtectionBypass
|
|
838
|
-
});
|
|
839
|
-
await patchGeminiSettingsEnv(sandbox, mcpEnv);
|
|
840
|
-
const args = [
|
|
841
|
-
"--prompt",
|
|
842
|
-
message,
|
|
843
|
-
"--output-format",
|
|
844
|
-
"stream-json",
|
|
845
|
-
"--approval-mode",
|
|
846
|
-
"yolo"
|
|
847
|
-
];
|
|
848
|
-
if (sessionId) {
|
|
849
|
-
args.push("--resume", sessionId);
|
|
850
|
-
}
|
|
851
|
-
await sandbox.runCommand({
|
|
852
|
-
cmd: "gemini",
|
|
853
|
-
args,
|
|
854
|
-
env: {
|
|
855
|
-
GEMINI_API_KEY: geminiApiKey
|
|
856
|
-
},
|
|
857
|
-
stdout: new Writable({
|
|
858
|
-
write(chunk, _encoding, callback) {
|
|
859
|
-
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
860
|
-
enqueueStdout(text);
|
|
861
|
-
callback();
|
|
862
|
-
}
|
|
863
|
-
}),
|
|
864
|
-
stderr: new Writable({
|
|
865
|
-
write(chunk, _encoding, callback) {
|
|
866
|
-
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
867
|
-
enqueueEvent({ type: "stderr", content: text });
|
|
868
|
-
callback();
|
|
869
|
-
}
|
|
870
|
-
}),
|
|
871
|
-
signal: abortController.signal
|
|
872
|
-
});
|
|
873
|
-
})().catch((error) => {
|
|
874
|
-
if (abortController.signal.aborted) {
|
|
875
|
-
return;
|
|
876
|
-
}
|
|
877
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
878
|
-
enqueueEvent({ type: "stderr", content: `[error] ${message}` });
|
|
879
|
-
}).finally(() => {
|
|
880
|
-
request.signal.removeEventListener("abort", onAbort);
|
|
881
|
-
close();
|
|
882
|
-
});
|
|
883
|
-
}
|
|
884
|
-
});
|
|
885
|
-
return new Response(stream, {
|
|
886
|
-
headers: {
|
|
887
|
-
"Content-Type": "application/x-ndjson; charset=utf-8",
|
|
888
|
-
"Cache-Control": "no-cache, no-transform"
|
|
889
|
-
}
|
|
890
|
-
});
|
|
886
|
+
GET: async (request) => createRelayEventsRoute(request),
|
|
887
|
+
POST: createRelayPostRoute
|
|
891
888
|
};
|
|
892
889
|
}
|
|
893
890
|
export {
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
bridgeRequestChannel,
|
|
897
|
-
createBridgeHandler,
|
|
898
|
-
createBridgeSession,
|
|
899
|
-
createBridgeSubscriber,
|
|
891
|
+
RELAY_SSE_KEEPALIVE_INTERVAL_MS,
|
|
892
|
+
assertRelaySession,
|
|
900
893
|
createGeminiChatHandler,
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
894
|
+
createRelayHandler,
|
|
895
|
+
createRelaySession,
|
|
896
|
+
createRelaySubscriber,
|
|
897
|
+
dispatchRelayRequest,
|
|
898
|
+
markBrowserConnected,
|
|
899
|
+
relayRequestChannel,
|
|
900
|
+
resolveRelayResponse,
|
|
901
|
+
toRelayError,
|
|
902
|
+
touchBrowserConnected
|
|
906
903
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@giselles-ai/sandbox-agent-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"format": "pnpm exec biome check --write ."
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@giselles-ai/browser-tool": "0.1.
|
|
29
|
+
"@giselles-ai/browser-tool": "0.1.4",
|
|
30
30
|
"@vercel/sandbox": "^1.0.0",
|
|
31
31
|
"ioredis": "^5.9.2",
|
|
32
32
|
"zod": "4.3.6"
|