@gitterm/sdk 0.10.0 → 0.12.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/README.md +37 -3
- package/dist/client.d.ts +54 -2
- package/dist/direct/boat.d.ts +2 -0
- package/dist/direct/index.d.ts +2 -2
- package/dist/direct/index.js +763 -346
- package/dist/direct/types.d.ts +9 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +50 -0
- package/dist/sandbox/artifacts.d.ts +33 -0
- package/dist/sandbox/backend.d.ts +98 -0
- package/dist/sandbox/client.d.ts +174 -0
- package/dist/sandbox/config.d.ts +128 -0
- package/dist/sandbox/index.d.ts +10 -0
- package/dist/sandbox/index.js +3974 -0
- package/dist/sandbox/jobs.d.ts +41 -0
- package/dist/sandbox/proxy.d.ts +21 -0
- package/dist/sandbox/setup-snapshots.d.ts +38 -0
- package/dist/sandbox/source.d.ts +57 -0
- package/dist/sandbox/store.d.ts +78 -0
- package/dist/sandbox/store.js +100 -0
- package/dist/sandbox/tar.d.ts +12 -0
- package/dist/sandbox/types.d.ts +39 -0
- package/dist/types.d.ts +53 -2
- package/package.json +15 -3
- package/dist/direct/ascii.d.ts +0 -2
|
@@ -0,0 +1,3974 @@
|
|
|
1
|
+
// ../agent-runtime/src/sandbox/types.ts
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
class SandboxProviderError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
leftoverId;
|
|
6
|
+
constructor(code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "SandboxProviderError";
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
async function discardFailedCreate(destroy, id, error) {
|
|
13
|
+
const deleted = await destroy(id).then(() => true, () => false);
|
|
14
|
+
if (deleted)
|
|
15
|
+
throw error;
|
|
16
|
+
const failed = error instanceof SandboxProviderError ? error : new SandboxProviderError("provider", error instanceof Error ? error.message : String(error));
|
|
17
|
+
failed.leftoverId = id;
|
|
18
|
+
throw failed;
|
|
19
|
+
}
|
|
20
|
+
var GITTERM_PREVIEW_PROVIDERS = [
|
|
21
|
+
"e2b",
|
|
22
|
+
"daytona",
|
|
23
|
+
"boat",
|
|
24
|
+
"exedev",
|
|
25
|
+
"gitterm"
|
|
26
|
+
];
|
|
27
|
+
var DAY = 24 * 60 * 60000;
|
|
28
|
+
var SANDBOX_PROVIDER_PROFILES = {
|
|
29
|
+
e2b: {
|
|
30
|
+
home: "/home/user",
|
|
31
|
+
capabilities: {
|
|
32
|
+
expiry: "hard",
|
|
33
|
+
previews: "either",
|
|
34
|
+
pause: "stateful",
|
|
35
|
+
maxLifetimeMs: DAY,
|
|
36
|
+
snapshots: "pauses"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
daytona: {
|
|
40
|
+
home: "/home/daytona",
|
|
41
|
+
capabilities: {
|
|
42
|
+
expiry: "hard",
|
|
43
|
+
previews: "either",
|
|
44
|
+
pause: "stateful",
|
|
45
|
+
maxLifetimeMs: DAY,
|
|
46
|
+
snapshots: "pauses"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
vercel: {
|
|
50
|
+
home: "/home/vercel-sandbox",
|
|
51
|
+
capabilities: {
|
|
52
|
+
expiry: "hard",
|
|
53
|
+
previews: "public",
|
|
54
|
+
pause: "stateful",
|
|
55
|
+
maxLifetimeMs: 5 * 60 * 60000,
|
|
56
|
+
snapshots: "stops"
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
boat: {
|
|
60
|
+
home: "/home/user",
|
|
61
|
+
capabilities: {
|
|
62
|
+
expiry: "archive",
|
|
63
|
+
previews: "private",
|
|
64
|
+
pause: "stateful",
|
|
65
|
+
maxLifetimeMs: DAY,
|
|
66
|
+
snapshots: "pauses"
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
exedev: {
|
|
70
|
+
home: "/home/exedev",
|
|
71
|
+
capabilities: {
|
|
72
|
+
expiry: "none",
|
|
73
|
+
previews: "private",
|
|
74
|
+
pause: "stateful",
|
|
75
|
+
maxLifetimeMs: DAY,
|
|
76
|
+
snapshots: "none"
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
railway: {
|
|
80
|
+
home: "/root",
|
|
81
|
+
capabilities: {
|
|
82
|
+
expiry: "none",
|
|
83
|
+
previews: "private",
|
|
84
|
+
pause: "state-losing",
|
|
85
|
+
maxLifetimeMs: DAY,
|
|
86
|
+
snapshots: "none"
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
aws: {
|
|
90
|
+
home: "/root",
|
|
91
|
+
capabilities: {
|
|
92
|
+
expiry: "none",
|
|
93
|
+
previews: "private",
|
|
94
|
+
pause: "state-losing",
|
|
95
|
+
maxLifetimeMs: DAY,
|
|
96
|
+
snapshots: "none"
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
gitterm: {
|
|
100
|
+
home: "/home/user",
|
|
101
|
+
capabilities: {
|
|
102
|
+
expiry: "hard",
|
|
103
|
+
previews: "private",
|
|
104
|
+
pause: "stateful",
|
|
105
|
+
maxLifetimeMs: DAY,
|
|
106
|
+
snapshots: "none"
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
function accountHash(account) {
|
|
111
|
+
return createHash("sha256").update(account).digest("hex").slice(0, 10);
|
|
112
|
+
}
|
|
113
|
+
var shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
114
|
+
function terminated(id) {
|
|
115
|
+
return { id, state: "terminated", labels: {}, startedAt: null, expiresAt: null };
|
|
116
|
+
}
|
|
117
|
+
function validatePort(port) {
|
|
118
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
119
|
+
throw new Error("Port must be between 1 and 65535");
|
|
120
|
+
}
|
|
121
|
+
function validateLifetime(timeoutMs, maxLifetimeMs) {
|
|
122
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 60000)
|
|
123
|
+
throw new Error("Sandbox lifetime must be at least one minute");
|
|
124
|
+
if (timeoutMs > maxLifetimeMs)
|
|
125
|
+
throw new Error(`Sandbox lifetime cannot exceed ${Math.floor(maxLifetimeMs / 60000)} minutes on this provider`);
|
|
126
|
+
}
|
|
127
|
+
function classifyFailure(provider, error, action, hints = {}) {
|
|
128
|
+
if (error instanceof SandboxProviderError)
|
|
129
|
+
return error;
|
|
130
|
+
const status = error?.status ?? error?.statusCode ?? error?.response?.status;
|
|
131
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
132
|
+
if (hints.auth?.(error) || status === 401 || status === 403 || /unauthori[sz]ed|not authori[sz]ed|invalid (api )?(key|token)|forbidden/i.test(message))
|
|
133
|
+
return new SandboxProviderError("auth", `${provider} rejected the credential while trying to ${action}.`);
|
|
134
|
+
if (hints.notFound?.(error) || status === 404 || /not found|does not exist|no such/i.test(message))
|
|
135
|
+
return new SandboxProviderError("not_found", `${provider} could not find the sandbox while trying to ${action}. It may have expired or belong to another account.`);
|
|
136
|
+
if (status === 429 || /rate limit|too many requests/i.test(message))
|
|
137
|
+
return new SandboxProviderError("rate_limited", `${provider} rate-limited the request to ${action}.`);
|
|
138
|
+
if (/timed? ?out|timeout/i.test(message))
|
|
139
|
+
return new SandboxProviderError("timeout", `${provider} timed out while trying to ${action}.`);
|
|
140
|
+
return new SandboxProviderError("provider", `${provider} failed to ${action}.`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/errors.ts
|
|
144
|
+
var WORKSPACE_LIFECYCLE_ERROR_CODES = [
|
|
145
|
+
"WORKSPACE_NOT_RUNNING",
|
|
146
|
+
"WORKSPACE_TERMINATED",
|
|
147
|
+
"WORKSPACE_NON_RECOVERABLE",
|
|
148
|
+
"WORKSPACE_START_TIMEOUT",
|
|
149
|
+
"WORKSPACE_RESTART_FAILED"
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
class GittermError extends Error {
|
|
153
|
+
code;
|
|
154
|
+
status;
|
|
155
|
+
constructor(code, message, options = {}) {
|
|
156
|
+
super(message, "cause" in options ? { cause: options.cause } : undefined);
|
|
157
|
+
this.name = "GittermError";
|
|
158
|
+
this.code = code;
|
|
159
|
+
this.status = options.status;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
class WorkspaceLifecycleError extends GittermError {
|
|
164
|
+
constructor(code, message, options = {}) {
|
|
165
|
+
super(code, message, options);
|
|
166
|
+
this.name = "WorkspaceLifecycleError";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
class AgentRunError extends GittermError {
|
|
171
|
+
run;
|
|
172
|
+
constructor(run, code) {
|
|
173
|
+
super(code, run.error ?? (code === "INPUT_REQUIRED" ? "The agent needs input; supply a handler or use runs.respond()" : `Agent run ${run.status}`));
|
|
174
|
+
this.run = run;
|
|
175
|
+
this.name = "AgentRunError";
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/sandbox/backend.ts
|
|
180
|
+
function managedCapabilities(provider) {
|
|
181
|
+
const { capabilities } = SANDBOX_PROVIDER_PROFILES[provider];
|
|
182
|
+
return GITTERM_PREVIEW_PROVIDERS.includes(provider) ? { ...capabilities, previews: "either" } : capabilities;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
class SandboxAccountError extends GittermError {
|
|
186
|
+
constructor(message) {
|
|
187
|
+
super("FORBIDDEN", message);
|
|
188
|
+
this.name = "SandboxAccountError";
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
var ACCOUNT_LABEL = "gitterm_account";
|
|
192
|
+
var WORKLOAD_LABEL = "gitterm_workload";
|
|
193
|
+
var NAME_LABEL = "gitterm_name";
|
|
194
|
+
function validateAccountName(name) {
|
|
195
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/.test(name))
|
|
196
|
+
throw new Error(`Invalid sandbox account name: ${JSON.stringify(name)}`);
|
|
197
|
+
return name;
|
|
198
|
+
}
|
|
199
|
+
function createDirectSandboxBackend(options) {
|
|
200
|
+
const account = validateAccountName(options.account);
|
|
201
|
+
const { transport } = options;
|
|
202
|
+
const identity = { mode: "direct", provider: transport.provider, account };
|
|
203
|
+
const toInfo = (info) => ({
|
|
204
|
+
...identity,
|
|
205
|
+
id: info.id,
|
|
206
|
+
state: info.state,
|
|
207
|
+
name: info.labels[NAME_LABEL] ?? null,
|
|
208
|
+
createdAt: info.startedAt,
|
|
209
|
+
expiresAt: info.expiresAt,
|
|
210
|
+
labels: info.labels
|
|
211
|
+
});
|
|
212
|
+
const owned = async (id) => {
|
|
213
|
+
const info = await transport.info(id);
|
|
214
|
+
const ownedByAccount = info.labels[ACCOUNT_LABEL] === account || info.labels.gitterm_account_hash === accountHash(account);
|
|
215
|
+
if (info.state !== "terminated" && !ownedByAccount)
|
|
216
|
+
throw new SandboxAccountError(`Sandbox ${id} was not created by direct account ${JSON.stringify(account)}. Select the account that created it.`);
|
|
217
|
+
return info;
|
|
218
|
+
};
|
|
219
|
+
const running = async (id) => {
|
|
220
|
+
const info = await owned(id);
|
|
221
|
+
if (info.state === "terminated")
|
|
222
|
+
throw new SandboxProviderError("not_found", `Sandbox ${id} no longer exists.`);
|
|
223
|
+
};
|
|
224
|
+
return {
|
|
225
|
+
identity,
|
|
226
|
+
home: transport.home,
|
|
227
|
+
capabilities: transport.capabilities,
|
|
228
|
+
async create(input) {
|
|
229
|
+
if (transport.capabilities.expiry === "none" && !options.allowUnboundedLifetime)
|
|
230
|
+
throw new Error(`${transport.provider} never deletes an abandoned sandbox, so a crashed caller would leave it running and billing. Set "allowUnboundedLifetime": true on account ${account} to accept that and clean up with destroy, or use a managed account (the Gitterm worker enforces lifetimes).`);
|
|
231
|
+
return toInfo(await transport.create({
|
|
232
|
+
labels: {
|
|
233
|
+
...input.labels,
|
|
234
|
+
[ACCOUNT_LABEL]: account,
|
|
235
|
+
[WORKLOAD_LABEL]: "sandbox",
|
|
236
|
+
[NAME_LABEL]: input.name.slice(0, 120)
|
|
237
|
+
},
|
|
238
|
+
timeoutMs: input.lifetimeMs,
|
|
239
|
+
onTimeout: "kill",
|
|
240
|
+
publicPreviews: input.publicPreviews,
|
|
241
|
+
...input.snapshot ? { snapshot: input.snapshot } : {}
|
|
242
|
+
}));
|
|
243
|
+
},
|
|
244
|
+
async get(id) {
|
|
245
|
+
return toInfo(await owned(id));
|
|
246
|
+
},
|
|
247
|
+
async list() {
|
|
248
|
+
return (await transport.list({ [ACCOUNT_LABEL]: account, [WORKLOAD_LABEL]: "sandbox" })).map(toInfo);
|
|
249
|
+
},
|
|
250
|
+
async destroy(id) {
|
|
251
|
+
await owned(id);
|
|
252
|
+
await transport.destroy(id);
|
|
253
|
+
},
|
|
254
|
+
async run(id, command, runOptions) {
|
|
255
|
+
await running(id);
|
|
256
|
+
return transport.run(id, command, runOptions);
|
|
257
|
+
},
|
|
258
|
+
async writeFile(id, path, data) {
|
|
259
|
+
await running(id);
|
|
260
|
+
return transport.writeFile(id, path, data);
|
|
261
|
+
},
|
|
262
|
+
async readFile(id, path) {
|
|
263
|
+
await running(id);
|
|
264
|
+
return transport.readFile(id, path);
|
|
265
|
+
},
|
|
266
|
+
async preview(id, port) {
|
|
267
|
+
await running(id);
|
|
268
|
+
return transport.preview(id, port);
|
|
269
|
+
},
|
|
270
|
+
...transport.snapshots ? {
|
|
271
|
+
snapshots: {
|
|
272
|
+
effect: transport.capabilities.snapshots,
|
|
273
|
+
async create(id, name) {
|
|
274
|
+
await running(id);
|
|
275
|
+
return transport.snapshots.create(id, name);
|
|
276
|
+
},
|
|
277
|
+
delete: (snapshotId) => transport.snapshots.delete(snapshotId)
|
|
278
|
+
}
|
|
279
|
+
} : {}
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
var MANAGED_CHUNK_BYTES = 6 * 1024 * 1024;
|
|
283
|
+
function createManagedSandboxBackend(options) {
|
|
284
|
+
const account = validateAccountName(options.account);
|
|
285
|
+
const { client, provider } = options;
|
|
286
|
+
const identity = { mode: "managed", provider, account };
|
|
287
|
+
const toInfo = (sandbox) => {
|
|
288
|
+
if (sandbox.provider !== provider)
|
|
289
|
+
throw new SandboxAccountError(`Sandbox ${sandbox.id} runs on ${sandbox.provider}, but managed account ${JSON.stringify(account)} is configured for ${provider}.`);
|
|
290
|
+
return {
|
|
291
|
+
...identity,
|
|
292
|
+
id: sandbox.id,
|
|
293
|
+
state: sandbox.status === "terminated" && !sandbox.deleted ? "deleting" : sandbox.status,
|
|
294
|
+
name: sandbox.name,
|
|
295
|
+
createdAt: sandbox.createdAt,
|
|
296
|
+
expiresAt: sandbox.expiresAt,
|
|
297
|
+
labels: sandbox.metadata
|
|
298
|
+
};
|
|
299
|
+
};
|
|
300
|
+
return {
|
|
301
|
+
identity,
|
|
302
|
+
home: SANDBOX_PROVIDER_PROFILES[provider].home,
|
|
303
|
+
capabilities: managedCapabilities(provider),
|
|
304
|
+
async create(input) {
|
|
305
|
+
return toInfo(await client.sandboxes.create({
|
|
306
|
+
provider,
|
|
307
|
+
name: input.name.slice(0, 120),
|
|
308
|
+
machine: options.machine,
|
|
309
|
+
lifetimeMinutes: Math.ceil(input.lifetimeMs / 60000),
|
|
310
|
+
publicPreviews: input.publicPreviews,
|
|
311
|
+
metadata: input.labels,
|
|
312
|
+
...input.snapshot ? { snapshotId: input.snapshot } : {}
|
|
313
|
+
}));
|
|
314
|
+
},
|
|
315
|
+
async get(id) {
|
|
316
|
+
try {
|
|
317
|
+
return toInfo(await client.sandboxes.get(id));
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if (error instanceof GittermError && error.code === "NOT_FOUND")
|
|
320
|
+
return {
|
|
321
|
+
...identity,
|
|
322
|
+
id,
|
|
323
|
+
state: "terminated",
|
|
324
|
+
name: null,
|
|
325
|
+
createdAt: null,
|
|
326
|
+
expiresAt: null,
|
|
327
|
+
labels: {}
|
|
328
|
+
};
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
async list() {
|
|
333
|
+
return (await client.sandboxes.list()).filter((sandbox) => sandbox.provider === provider).map(toInfo);
|
|
334
|
+
},
|
|
335
|
+
destroy: (id) => client.sandboxes.terminate(id),
|
|
336
|
+
resume: (id) => client.sandboxes.resume(id),
|
|
337
|
+
...provider === "gitterm" ? { onJobFinished: (listener) => client.sandboxes.onJobFinished(listener) } : {},
|
|
338
|
+
run: (id, command, runOptions) => client.sandboxes.run(id, command, runOptions),
|
|
339
|
+
async writeFile(id, path, data) {
|
|
340
|
+
if (data.byteLength <= MANAGED_CHUNK_BYTES)
|
|
341
|
+
return client.sandboxes.writeFile(id, path, data);
|
|
342
|
+
const parts = [];
|
|
343
|
+
for (let offset = 0;offset < data.byteLength; offset += MANAGED_CHUNK_BYTES) {
|
|
344
|
+
const part = `${path}.part${parts.length}`;
|
|
345
|
+
await client.sandboxes.writeFile(id, part, data.subarray(offset, offset + MANAGED_CHUNK_BYTES));
|
|
346
|
+
parts.push(part);
|
|
347
|
+
}
|
|
348
|
+
const quoted = parts.map((part) => `'${part.replaceAll("'", `'"'"'`)}'`).join(" ");
|
|
349
|
+
const target = `'${path.replaceAll("'", `'"'"'`)}'`;
|
|
350
|
+
const joined = await client.sandboxes.run(id, `cat ${quoted} > ${target} && rm -f ${quoted}`);
|
|
351
|
+
if (joined.exitCode !== 0)
|
|
352
|
+
throw new Error("Could not assemble the uploaded file in the sandbox");
|
|
353
|
+
},
|
|
354
|
+
readFile: (id, path) => client.sandboxes.readFile(id, path),
|
|
355
|
+
preview: (id, port, previewOptions) => client.sandboxes.preview(id, port, previewOptions),
|
|
356
|
+
...SANDBOX_PROVIDER_PROFILES[provider].capabilities.snapshots !== "none" ? {
|
|
357
|
+
snapshots: {
|
|
358
|
+
effect: SANDBOX_PROVIDER_PROFILES[provider].capabilities.snapshots,
|
|
359
|
+
create: async (id, name) => (await client.sandboxes.saveSnapshot(id, name)).id,
|
|
360
|
+
delete: (snapshotId) => client.sandboxes.deleteSnapshot(snapshotId)
|
|
361
|
+
}
|
|
362
|
+
} : {}
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
// src/sandbox/types.ts
|
|
366
|
+
function isSandboxProviderError(error) {
|
|
367
|
+
return error instanceof Error && error.name === "SandboxProviderError" && "code" in error;
|
|
368
|
+
}
|
|
369
|
+
// src/sandbox/client.ts
|
|
370
|
+
import path5 from "node:path";
|
|
371
|
+
|
|
372
|
+
// src/sandbox/artifacts.ts
|
|
373
|
+
import { createHash as createHash3, randomBytes as randomBytes2 } from "node:crypto";
|
|
374
|
+
import { chmod, lstat as lstat2, mkdir, readFile as readFile2, realpath as realpath2, rename, rm, writeFile } from "node:fs/promises";
|
|
375
|
+
import { homedir } from "node:os";
|
|
376
|
+
import path2 from "node:path";
|
|
377
|
+
|
|
378
|
+
// src/sandbox/source.ts
|
|
379
|
+
import { createHash as createHash2, randomBytes } from "node:crypto";
|
|
380
|
+
import { execFile } from "node:child_process";
|
|
381
|
+
import { lstat, readFile, readlink, realpath } from "node:fs/promises";
|
|
382
|
+
import path from "node:path";
|
|
383
|
+
|
|
384
|
+
// src/sandbox/tar.ts
|
|
385
|
+
import { gzipSync } from "node:zlib";
|
|
386
|
+
var BLOCK = 512;
|
|
387
|
+
function octal(value, width) {
|
|
388
|
+
return value.toString(8).padStart(width - 1, "0") + "\x00";
|
|
389
|
+
}
|
|
390
|
+
function header(name, options) {
|
|
391
|
+
const block = Buffer.alloc(BLOCK);
|
|
392
|
+
block.write(name, 0, 100, "utf8");
|
|
393
|
+
block.write(octal(options.mode, 8), 100, "ascii");
|
|
394
|
+
block.write(octal(0, 8), 108, "ascii");
|
|
395
|
+
block.write(octal(0, 8), 116, "ascii");
|
|
396
|
+
block.write(octal(options.size, 12), 124, "ascii");
|
|
397
|
+
block.write(octal(0, 12), 136, "ascii");
|
|
398
|
+
block.write(" ", 148, "ascii");
|
|
399
|
+
block.write(options.type, 156, "ascii");
|
|
400
|
+
if (options.link)
|
|
401
|
+
block.write(options.link, 157, 100, "utf8");
|
|
402
|
+
block.write("ustar\x00", 257, "ascii");
|
|
403
|
+
block.write("00", 263, "ascii");
|
|
404
|
+
let sum = 0;
|
|
405
|
+
for (const byte of block)
|
|
406
|
+
sum += byte;
|
|
407
|
+
block.write(octal(sum, 7) + " ", 148, "ascii");
|
|
408
|
+
return block;
|
|
409
|
+
}
|
|
410
|
+
function paxRecord(key, value) {
|
|
411
|
+
const body = ` ${key}=${value}
|
|
412
|
+
`;
|
|
413
|
+
const bytes = Buffer.byteLength(body);
|
|
414
|
+
let length = bytes + 1;
|
|
415
|
+
while (String(length).length + bytes !== length)
|
|
416
|
+
length = String(length).length + bytes;
|
|
417
|
+
return Buffer.from(`${length}${body}`);
|
|
418
|
+
}
|
|
419
|
+
function padded(data) {
|
|
420
|
+
const remainder = data.byteLength % BLOCK;
|
|
421
|
+
return remainder ? [Buffer.from(data), Buffer.alloc(BLOCK - remainder)] : [Buffer.from(data)];
|
|
422
|
+
}
|
|
423
|
+
function createTarGz(entries) {
|
|
424
|
+
const chunks = [];
|
|
425
|
+
for (const entry of entries) {
|
|
426
|
+
const link = entry.type === "symlink" ? entry.target : undefined;
|
|
427
|
+
const needsPax = Buffer.byteLength(entry.path) > 99 || /[^\x20-\x7e]/.test(entry.path) || link !== undefined && (Buffer.byteLength(link) > 99 || /[^\x20-\x7e]/.test(link));
|
|
428
|
+
if (needsPax) {
|
|
429
|
+
const records = Buffer.concat([
|
|
430
|
+
paxRecord("path", entry.path),
|
|
431
|
+
...link !== undefined ? [paxRecord("linkpath", link)] : []
|
|
432
|
+
]);
|
|
433
|
+
chunks.push(header("pax-header", { size: records.length, mode: 420, type: "x" }));
|
|
434
|
+
chunks.push(...padded(records));
|
|
435
|
+
}
|
|
436
|
+
const name = needsPax ? `gitterm-pax/${chunks.length}` : entry.path;
|
|
437
|
+
if (entry.type === "symlink") {
|
|
438
|
+
chunks.push(header(name, { size: 0, mode: 511, type: "2", link: needsPax ? "" : link }));
|
|
439
|
+
} else {
|
|
440
|
+
chunks.push(header(name, {
|
|
441
|
+
size: entry.data.byteLength,
|
|
442
|
+
mode: entry.executable ? 493 : 420,
|
|
443
|
+
type: "0"
|
|
444
|
+
}));
|
|
445
|
+
chunks.push(...padded(entry.data));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
chunks.push(Buffer.alloc(BLOCK * 2));
|
|
449
|
+
return gzipSync(Buffer.concat(chunks));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// src/sandbox/source.ts
|
|
453
|
+
var projectDir = (home) => `${home}/project`;
|
|
454
|
+
var stateDir = (home) => `${home}/.gitterm/source`;
|
|
455
|
+
var MAX_SOURCE_BYTES = 100 * 1024 * 1024;
|
|
456
|
+
function git(cwd, args) {
|
|
457
|
+
return new Promise((resolve, reject) => {
|
|
458
|
+
execFile("git", ["-c", "core.quotePath=false", ...args], {
|
|
459
|
+
cwd,
|
|
460
|
+
encoding: "buffer",
|
|
461
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
462
|
+
timeout: 60000,
|
|
463
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" }
|
|
464
|
+
}, (error, stdout) => error ? reject(new Error(`git ${args[0]} failed in the source checkout`)) : resolve(stdout));
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
var nulList = (buffer) => buffer.toString("utf8").split("\x00").filter(Boolean);
|
|
468
|
+
function safeRelativePath(file) {
|
|
469
|
+
return Boolean(file) && !["\\", "\x00", `
|
|
470
|
+
`, "\r"].some((character) => file.includes(character)) && !path.posix.isAbsolute(file) && file.split("/").every((part) => part !== "" && part !== "." && part !== ".." && part.toLowerCase() !== ".git");
|
|
471
|
+
}
|
|
472
|
+
function sensitivePath(file) {
|
|
473
|
+
return /(^|\/)(\.env($|\.(?!(?:[^/]*\.)?(?:example|sample|template)$))|\.npmrc$|\.netrc$|\.pypirc$|id_(rsa|ed25519|ecdsa)($|\.)|credentials(?:\.json)?$)|\.(pem|p12|pfx|key)$/i.test(file);
|
|
474
|
+
}
|
|
475
|
+
var PRIVATE_KEY = /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----(?:\r?\n|(?:\\r)?\\n)(?:[A-Za-z-]+: [^\r\n]*(?:\r?\n|(?:\\r)?\\n))*(?:\r?\n|(?:\\r)?\\n)?[A-Za-z0-9+/]{16}/;
|
|
476
|
+
function globToRegExp(glob) {
|
|
477
|
+
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*\/?/g, "\x00").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").replaceAll("\x00", ".*");
|
|
478
|
+
return new RegExp(glob.includes("/") ? `^${escaped}$` : `(^|/)${escaped}$`);
|
|
479
|
+
}
|
|
480
|
+
async function captureSource(directory, options = {}) {
|
|
481
|
+
let root;
|
|
482
|
+
try {
|
|
483
|
+
root = await realpath((await git(directory, ["rev-parse", "--show-toplevel"])).toString().trim());
|
|
484
|
+
} catch {
|
|
485
|
+
throw new Error("Sandbox sync needs a Git checkout so ignored files can be excluded.");
|
|
486
|
+
}
|
|
487
|
+
if ((await git(root, ["ls-files", "--unmerged", "-z"])).length)
|
|
488
|
+
throw new Error("Resolve merge conflicts before syncing to a sandbox.");
|
|
489
|
+
const staged = (await git(root, ["ls-files", "--stage", "-z"])).toString().split("\x00");
|
|
490
|
+
if (staged.some((line) => line.startsWith("160000 ")))
|
|
491
|
+
throw new Error("Submodules are not supported by sandbox sync yet.");
|
|
492
|
+
const head = await git(root, ["rev-parse", "--verify", "-q", "HEAD"]).then((value) => value.toString().trim()).catch(() => null);
|
|
493
|
+
const tracked = nulList(await git(root, ["ls-files", "-z", "--cached"]));
|
|
494
|
+
const deleted = new Set(nulList(await git(root, ["ls-files", "-z", "--deleted"])));
|
|
495
|
+
const modified = nulList(await git(root, ["ls-files", "-z", "--modified"])).filter((file) => !deleted.has(file));
|
|
496
|
+
const untracked = nulList(await git(root, ["ls-files", "-z", "--others", "--exclude-standard"]));
|
|
497
|
+
const exclude = (options.exclude ?? []).map(globToRegExp);
|
|
498
|
+
const maxBytes = options.maxBytes ?? MAX_SOURCE_BYTES;
|
|
499
|
+
const candidates = [...new Set([...tracked.filter((file) => !deleted.has(file)), ...untracked])].toSorted((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)));
|
|
500
|
+
const files = [];
|
|
501
|
+
const entries = [];
|
|
502
|
+
const excluded = [];
|
|
503
|
+
let bytes = 0;
|
|
504
|
+
for (const file of candidates) {
|
|
505
|
+
if (!safeRelativePath(file)) {
|
|
506
|
+
excluded.push({ path: file, reason: "unsupported path" });
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (sensitivePath(file)) {
|
|
510
|
+
excluded.push({ path: file, reason: "secret-like file name" });
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (exclude.some((pattern) => pattern.test(file))) {
|
|
514
|
+
excluded.push({ path: file, reason: "excluded by configuration" });
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
const full = path.join(root, file);
|
|
518
|
+
const info = await lstat(full).catch(() => null);
|
|
519
|
+
if (!info)
|
|
520
|
+
continue;
|
|
521
|
+
if (info.isSymbolicLink()) {
|
|
522
|
+
const target = await readlink(full);
|
|
523
|
+
files.push({
|
|
524
|
+
path: file,
|
|
525
|
+
kind: "symlink",
|
|
526
|
+
size: 0,
|
|
527
|
+
sha256: createHash2("sha256").update(target).digest("hex")
|
|
528
|
+
});
|
|
529
|
+
entries.push({ path: file, type: "symlink", target });
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (!info.isFile()) {
|
|
533
|
+
excluded.push({ path: file, reason: "not a regular file" });
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
bytes += info.size;
|
|
537
|
+
if (bytes > maxBytes)
|
|
538
|
+
throw new Error(`Source exceeds the ${Math.round(maxBytes / 1024 / 1024)} MiB sandbox sync limit. Exclude build output or large files.`);
|
|
539
|
+
const data = await readFile(full);
|
|
540
|
+
if (PRIVATE_KEY.test(data.subarray(0, 4 * 1024 * 1024).toString("latin1"))) {
|
|
541
|
+
excluded.push({ path: file, reason: "contains private key material" });
|
|
542
|
+
bytes -= info.size;
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
const executable = Boolean(info.mode & 73);
|
|
546
|
+
files.push({
|
|
547
|
+
path: file,
|
|
548
|
+
kind: executable ? "executable" : "file",
|
|
549
|
+
size: data.byteLength,
|
|
550
|
+
sha256: createHash2("sha256").update(data).digest("hex")
|
|
551
|
+
});
|
|
552
|
+
entries.push({ path: file, type: "file", data, executable });
|
|
553
|
+
}
|
|
554
|
+
const digest = createHash2("sha256").update(manifestText(files)).digest("hex");
|
|
555
|
+
const included = new Set(files.map((file) => file.path));
|
|
556
|
+
return {
|
|
557
|
+
snapshot: {
|
|
558
|
+
id: `snap_${digest.slice(0, 16)}`,
|
|
559
|
+
digest,
|
|
560
|
+
root,
|
|
561
|
+
head,
|
|
562
|
+
files,
|
|
563
|
+
deleted: [...deleted].toSorted(),
|
|
564
|
+
modified: modified.filter((file) => included.has(file)),
|
|
565
|
+
untracked: untracked.filter((file) => included.has(file)),
|
|
566
|
+
excluded,
|
|
567
|
+
bytes
|
|
568
|
+
},
|
|
569
|
+
archive: createTarGz(entries)
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
function manifestText(files) {
|
|
573
|
+
return files.map((file) => `${file.kind} ${file.sha256} ${file.path}
|
|
574
|
+
`).join("");
|
|
575
|
+
}
|
|
576
|
+
var q = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
577
|
+
async function uploadSource(backend, sandboxId, captured) {
|
|
578
|
+
const { snapshot, archive } = captured;
|
|
579
|
+
const STATE_DIR = stateDir(backend.home);
|
|
580
|
+
const PROJECT_DIR = projectDir(backend.home);
|
|
581
|
+
const base = `${STATE_DIR}/${snapshot.id}-${randomBytes(4).toString("hex")}`;
|
|
582
|
+
const list = snapshot.files.map((file) => `${file.path}
|
|
583
|
+
`).join("");
|
|
584
|
+
const sums = snapshot.files.filter((file) => file.kind !== "symlink").map((file) => `${file.sha256} ${file.path}
|
|
585
|
+
`).join("");
|
|
586
|
+
const prepared = await backend.run(sandboxId, `mkdir -p ${q(STATE_DIR)} ${q(PROJECT_DIR)}`);
|
|
587
|
+
if (prepared.exitCode !== 0)
|
|
588
|
+
throw new Error("Could not prepare the sandbox for upload");
|
|
589
|
+
await backend.writeFile(sandboxId, `${base}.tgz`, archive);
|
|
590
|
+
await backend.writeFile(sandboxId, `${base}.files`, Buffer.from(list));
|
|
591
|
+
await backend.writeFile(sandboxId, `${base}.sha256`, Buffer.from(sums));
|
|
592
|
+
const result = await backend.run(sandboxId, [
|
|
593
|
+
"set -eu",
|
|
594
|
+
"export LC_ALL=C",
|
|
595
|
+
`B=${q(base)}; P=${q(PROJECT_DIR)}; S=${q(STATE_DIR)}`,
|
|
596
|
+
`trap 'rm -rf "$B.stage" "$B.tgz" "$B.sha256" "$B.files"' EXIT`,
|
|
597
|
+
'mkdir "$B.stage"',
|
|
598
|
+
'tar -xzf "$B.tgz" -C "$B.stage" --no-same-owner',
|
|
599
|
+
'for f in "$B.stage"/gitterm-pax/*; do [ -e "$f" ] || break; echo "unexpected pax placeholder" >&2; exit 4; done',
|
|
600
|
+
'verify() { [ ! -s "$B.sha256" ] || (cd "$1" && sha256sum -c --quiet --strict "$B.sha256"); }',
|
|
601
|
+
'verify "$B.stage" || { echo "staged files failed verification" >&2; exit 5; }',
|
|
602
|
+
'L="$S/publish.lock"; n=0',
|
|
603
|
+
'until mkdir "$L" 2>/dev/null; do',
|
|
604
|
+
' if [ -n "$(find "$L" -maxdepth 0 -mmin +5 2>/dev/null)" ]; then rm -rf "$L"; continue; fi',
|
|
605
|
+
' n=$((n + 1)); [ "$n" -lt 450 ] || { echo "another sync is still publishing" >&2; exit 7; }',
|
|
606
|
+
" sleep 0.2",
|
|
607
|
+
"done",
|
|
608
|
+
`trap 'rm -rf "$B.stage" "$B.tgz" "$B.sha256" "$B.files" "$L"' EXIT`,
|
|
609
|
+
'sort -o "$B.files" "$B.files"',
|
|
610
|
+
'if [ -f "$S/current.files" ]; then comm -23 "$S/current.files" "$B.files" | while IFS= read -r f; do rm -f -- "$P/$f"; done; fi',
|
|
611
|
+
'while IFS= read -r f; do if [ -d "$P/$f" ] && [ ! -L "$P/$f" ]; then rm -rf -- "$P/$f"; fi; done < "$B.files"',
|
|
612
|
+
'cp -a "$B.stage/." "$P/"',
|
|
613
|
+
'verify "$P" || { echo "project files failed verification" >&2; exit 6; }',
|
|
614
|
+
'mv "$B.files" "$S/current.files"',
|
|
615
|
+
`printf %s ${q(snapshot.digest)} > "$S/current.digest"`,
|
|
616
|
+
"echo GITTERM_SOURCE_VERIFIED"
|
|
617
|
+
].join(`
|
|
618
|
+
`), { timeoutMs: 120000 });
|
|
619
|
+
if (result.exitCode !== 0 || !result.stdout.includes("GITTERM_SOURCE_VERIFIED"))
|
|
620
|
+
throw new Error(`Source upload failed verification in the sandbox (exit ${result.exitCode}). ${result.stderr.trim().slice(-300)}`);
|
|
621
|
+
return { verified: true, digest: snapshot.digest };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// src/sandbox/artifacts.ts
|
|
625
|
+
var MAX_ARTIFACT_BYTES = 64 * 1024 * 1024;
|
|
626
|
+
function artifactDirectory() {
|
|
627
|
+
const base = process.env.XDG_CACHE_HOME || path2.join(homedir(), ".cache");
|
|
628
|
+
return path2.join(base, "gitterm", "sandbox-artifacts");
|
|
629
|
+
}
|
|
630
|
+
var q2 = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
631
|
+
function remotePath(home, file) {
|
|
632
|
+
const resolved = file.startsWith("/") ? path2.posix.normalize(file) : path2.posix.join(projectDir(home), file);
|
|
633
|
+
if (resolved.includes("\x00") || resolved.split("/").includes(".."))
|
|
634
|
+
throw new Error("Invalid sandbox path");
|
|
635
|
+
return resolved;
|
|
636
|
+
}
|
|
637
|
+
async function fetchArtifact(backend, sandboxId, file, options = {}) {
|
|
638
|
+
const remote = remotePath(backend.home, file);
|
|
639
|
+
const maxBytes = options.maxBytes ?? MAX_ARTIFACT_BYTES;
|
|
640
|
+
const probe = await backend.run(sandboxId, `[ -f ${q2(remote)} ] || exit 3; wc -c < ${q2(remote)}; sha256sum ${q2(remote)} | cut -d' ' -f1`, { timeoutMs: 60000 });
|
|
641
|
+
if (probe.exitCode === 3)
|
|
642
|
+
throw new Error(`No regular file at ${remote} in the sandbox`);
|
|
643
|
+
if (probe.exitCode !== 0)
|
|
644
|
+
throw new Error("Could not inspect the artifact in the sandbox");
|
|
645
|
+
const [sizeLine, remoteHash] = probe.stdout.trim().split(`
|
|
646
|
+
`);
|
|
647
|
+
if (Number(sizeLine) > maxBytes)
|
|
648
|
+
throw new Error(`Artifact is larger than ${Math.round(maxBytes / 1024 / 1024)} MiB`);
|
|
649
|
+
const data = await backend.readFile(sandboxId, remote);
|
|
650
|
+
const sha256 = createHash3("sha256").update(data).digest("hex");
|
|
651
|
+
if (sha256 !== remoteHash?.trim())
|
|
652
|
+
throw new Error(`${remote} changed while it was downloaded. Fetch it again once nothing is writing it.`);
|
|
653
|
+
const directory = path2.join(artifactDirectory(), `${backend.identity.mode}-${backend.identity.account}`, sandboxId);
|
|
654
|
+
await mkdir(directory, { recursive: true, mode: 448 });
|
|
655
|
+
const localPath = path2.join(directory, `${sha256.slice(0, 12)}-${path2.posix.basename(remote)}`);
|
|
656
|
+
await writeFile(localPath, data, { mode: 384 });
|
|
657
|
+
return {
|
|
658
|
+
remotePath: remote,
|
|
659
|
+
localPath,
|
|
660
|
+
sha256,
|
|
661
|
+
bytes: data.byteLength,
|
|
662
|
+
verified: true
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
class ArtifactConflictError extends Error {
|
|
667
|
+
constructor(message) {
|
|
668
|
+
super(message);
|
|
669
|
+
this.name = "ArtifactConflictError";
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
async function applyArtifact(input) {
|
|
673
|
+
if (!safeRelativePath(input.target))
|
|
674
|
+
throw new Error("Target must be a relative path inside the checkout");
|
|
675
|
+
const data = await readFile2(input.artifact.localPath);
|
|
676
|
+
if (createHash3("sha256").update(data).digest("hex") !== input.artifact.sha256)
|
|
677
|
+
throw new ArtifactConflictError("The fetched artifact changed on disk since it was downloaded.");
|
|
678
|
+
const root = await realpath2(input.snapshot.root);
|
|
679
|
+
const parts = input.target.split("/");
|
|
680
|
+
let directory = root;
|
|
681
|
+
for (const part of parts.slice(0, -1)) {
|
|
682
|
+
directory = path2.join(directory, part);
|
|
683
|
+
const info = await lstat2(directory).catch(() => null);
|
|
684
|
+
if (!info)
|
|
685
|
+
await mkdir(directory);
|
|
686
|
+
else if (info.isSymbolicLink() || !info.isDirectory())
|
|
687
|
+
throw new ArtifactConflictError(`${path2.relative(root, directory)} is not a directory inside the checkout.`);
|
|
688
|
+
}
|
|
689
|
+
const destination = path2.join(directory, parts.at(-1));
|
|
690
|
+
const expected = input.snapshot.files.find((file) => file.path === input.target);
|
|
691
|
+
const current = await lstat2(destination).catch(() => null);
|
|
692
|
+
if (current?.isSymbolicLink() || current && !current.isFile())
|
|
693
|
+
throw new ArtifactConflictError(`${input.target} is not a regular file locally.`);
|
|
694
|
+
const currentHash = current ? createHash3("sha256").update(await readFile2(destination)).digest("hex") : null;
|
|
695
|
+
if ((expected?.sha256 ?? null) !== currentHash)
|
|
696
|
+
throw new ArtifactConflictError(`${input.target} changed locally since snapshot ${input.snapshot.id}. Sync again and rerun before applying.`);
|
|
697
|
+
const temporary = path2.join(directory, `.gitterm-${randomBytes2(6).toString("hex")}`);
|
|
698
|
+
try {
|
|
699
|
+
await writeFile(temporary, data, { flag: "wx" });
|
|
700
|
+
if (current)
|
|
701
|
+
await chmod(temporary, current.mode & 4095);
|
|
702
|
+
if (await realpath2(directory) !== directory)
|
|
703
|
+
throw new ArtifactConflictError("The checkout changed while applying the artifact.");
|
|
704
|
+
await rename(temporary, destination);
|
|
705
|
+
} finally {
|
|
706
|
+
await rm(temporary, { force: true });
|
|
707
|
+
}
|
|
708
|
+
return { path: input.target, sha256: input.artifact.sha256 };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// src/sandbox/jobs.ts
|
|
712
|
+
import { randomBytes as randomBytes3 } from "node:crypto";
|
|
713
|
+
var jobRoot = (home) => `${home}/.gitterm/jobs`;
|
|
714
|
+
var q3 = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
715
|
+
function newJobId() {
|
|
716
|
+
return `job_${randomBytes3(10).toString("hex")}`;
|
|
717
|
+
}
|
|
718
|
+
function jobDir(backend, id) {
|
|
719
|
+
if (!/^job_[a-f0-9]{20}$/.test(id))
|
|
720
|
+
throw new Error(`Invalid job id: ${id}`);
|
|
721
|
+
return `${jobRoot(backend.home)}/${id}`;
|
|
722
|
+
}
|
|
723
|
+
var NOW = "date -u +%Y-%m-%dT%H:%M:%S.%3NZ";
|
|
724
|
+
var LIVENESS = `group_alive() {
|
|
725
|
+
g=$1
|
|
726
|
+
for s in /proc/[0-9]*/stat; do
|
|
727
|
+
read -r line < "$s" 2>/dev/null || continue
|
|
728
|
+
set -- \${line##*) }
|
|
729
|
+
[ "$3" = "$g" ] || continue
|
|
730
|
+
[ "$1" = Z ] || return 0
|
|
731
|
+
done
|
|
732
|
+
return 1
|
|
733
|
+
}
|
|
734
|
+
pid_alive() {
|
|
735
|
+
read -r line < "/proc/$1/stat" 2>/dev/null || return 1
|
|
736
|
+
set -- \${line##*) }
|
|
737
|
+
[ "$1" != Z ]
|
|
738
|
+
}`;
|
|
739
|
+
var RUNNER = `J="$1"; T="$2"; C="$3"
|
|
740
|
+
${LIVENESS}
|
|
741
|
+
echo $$ > "$J/runner.pid"
|
|
742
|
+
exec > "$J/output.log" 2>&1 < /dev/null
|
|
743
|
+
# On gitterm.dev, Gitterm's own sandboxes have ~/.gitterm/callback.env, so a finished job is
|
|
744
|
+
# reported at once instead of polled. No other sandbox has the file, so nothing is sent.
|
|
745
|
+
report() {
|
|
746
|
+
F="$J/../../callback.env"
|
|
747
|
+
[ -r "$F" ] || return 0
|
|
748
|
+
( . "$F"
|
|
749
|
+
S=failed; [ "$1" = 0 ] && S=succeeded
|
|
750
|
+
[ -e "$J/timed_out" ] && S=timed_out; [ -e "$J/cancelled" ] && S=cancelled
|
|
751
|
+
B="{\\"sandboxId\\":\\"$GITTERM_CALLBACK_SANDBOX\\",\\"jobId\\":\\"$(basename "$J")\\",\\"status\\":\\"$S\\",\\"exitCode\\":$1}"
|
|
752
|
+
for i in 1 2 3; do
|
|
753
|
+
if command -v curl >/dev/null 2>&1; then
|
|
754
|
+
curl -fsS -m 10 -H "authorization: Bearer $GITTERM_CALLBACK_TOKEN" -H "content-type: application/json" -d "$B" "$GITTERM_CALLBACK_URL" >/dev/null 2>&1 && break
|
|
755
|
+
elif command -v wget >/dev/null 2>&1; then
|
|
756
|
+
wget -q -T 10 -O /dev/null --header="authorization: Bearer $GITTERM_CALLBACK_TOKEN" --header="content-type: application/json" --post-data="$B" "$GITTERM_CALLBACK_URL" && break
|
|
757
|
+
else break; fi
|
|
758
|
+
sleep "$i"
|
|
759
|
+
done ) > /dev/null 2>&1 || true
|
|
760
|
+
}
|
|
761
|
+
finish() { ${NOW} > "$J/finished"; echo "$1" > "$J/exit.tmp"; mv "$J/exit.tmp" "$J/exit"; report "$1"; exit "$1"; }
|
|
762
|
+
cd "$C" 2>/dev/null || { echo "gitterm: working directory not found: $C"; finish 127; }
|
|
763
|
+
if [ -s "$J/secret-names" ]; then
|
|
764
|
+
setsid -w sh -c 'echo $$ > "$0/pgid"; . "$0/env.sh"; { sh "$0/command.sh"; echo $? > "$0/command-exit"; } 2>&1 | awk -v names="$0/secret-names" -f "$0/redact.awk"; [ -e "$0/command-exit" ] && exit "$(cat "$0/command-exit")"; exit 143' "$J" &
|
|
765
|
+
else
|
|
766
|
+
setsid -w sh -c 'echo $$ > "$0/pgid"; . "$0/env.sh"; exec sh "$0/command.sh"' "$J" &
|
|
767
|
+
fi
|
|
768
|
+
child=$!
|
|
769
|
+
setsid sh -c 'sleep "$1"; [ -e "$0/exit" ] && exit 0; : > "$0/timed_out"; P=$(cat "$0/pgid"); kill -TERM -"$P" 2>/dev/null; sleep 10; kill -KILL -"$P" 2>/dev/null' "$J" "$T" > /dev/null 2>&1 &
|
|
770
|
+
dog=$!
|
|
771
|
+
wait "$child"; code=$?
|
|
772
|
+
P=$(cat "$J/pgid" 2>/dev/null || true)
|
|
773
|
+
if [ -n "$P" ] && group_alive "$P"; then
|
|
774
|
+
kill -TERM -"$P" 2>/dev/null
|
|
775
|
+
i=0; while [ $i -lt 50 ] && group_alive "$P"; do sleep 0.1; i=$((i+1)); done
|
|
776
|
+
kill -KILL -"$P" 2>/dev/null
|
|
777
|
+
fi
|
|
778
|
+
kill -TERM -"$dog" 2>/dev/null || kill "$dog" 2>/dev/null
|
|
779
|
+
finish "$code"
|
|
780
|
+
`;
|
|
781
|
+
var REDACT_AWK = `BEGIN { while ((getline name < names) > 0) if ((name in ENVIRON) && length(ENVIRON[name]) >= 4) secret[++n] = ENVIRON[name] }
|
|
782
|
+
{
|
|
783
|
+
line = $0
|
|
784
|
+
for (i = 1; i <= n; i++) {
|
|
785
|
+
out = ""
|
|
786
|
+
while ((at = index(line, secret[i])) > 0) { out = out substr(line, 1, at - 1) "[redacted]"; line = substr(line, at + length(secret[i])) }
|
|
787
|
+
line = out line
|
|
788
|
+
}
|
|
789
|
+
print line
|
|
790
|
+
fflush()
|
|
791
|
+
}
|
|
792
|
+
`;
|
|
793
|
+
function assertRedactableSecrets(env = {}) {
|
|
794
|
+
for (const [name, value] of Object.entries(env)) {
|
|
795
|
+
if (/[\r\n]/.test(value))
|
|
796
|
+
throw new Error(`${name} contains a line break, which cannot be redacted from output. Base64-encode it and decode it in the command, e.g. "$(printf %s "$${name}" | base64 -d)".`);
|
|
797
|
+
if (value.length < 4)
|
|
798
|
+
throw new Error(`${name} is shorter than 4 characters and cannot be redacted reliably.`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
function envScript(env = {}) {
|
|
802
|
+
return Object.entries(env).map(([key, value]) => {
|
|
803
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
804
|
+
throw new Error(`Invalid variable name: ${key}`);
|
|
805
|
+
return `export ${key}=${q3(value)}`;
|
|
806
|
+
}).join(`
|
|
807
|
+
`);
|
|
808
|
+
}
|
|
809
|
+
async function startJob(backend, sandboxId, id, input) {
|
|
810
|
+
assertRedactableSecrets(input.env);
|
|
811
|
+
const dir = jobDir(backend, id);
|
|
812
|
+
const seconds = Math.max(1, Math.ceil(input.timeoutMs / 1000));
|
|
813
|
+
const prepared = await backend.run(sandboxId, `mkdir -p ${q3(dir)} && chmod 700 ${q3(dir)} && [ ! -e ${q3(`${dir}/started`)} ]`, { timeoutMs: 30000 });
|
|
814
|
+
if (prepared.exitCode !== 0)
|
|
815
|
+
throw new Error(`Could not prepare job ${id} in the sandbox`);
|
|
816
|
+
await backend.writeFile(sandboxId, `${dir}/env.sh`, Buffer.from(envScript(input.env)));
|
|
817
|
+
await backend.writeFile(sandboxId, `${dir}/command.sh`, Buffer.from(input.command));
|
|
818
|
+
const names = Object.keys(input.env ?? {});
|
|
819
|
+
if (names.length) {
|
|
820
|
+
await backend.writeFile(sandboxId, `${dir}/secret-names`, Buffer.from(`${names.join(`
|
|
821
|
+
`)}
|
|
822
|
+
`));
|
|
823
|
+
await backend.writeFile(sandboxId, `${dir}/redact.awk`, Buffer.from(REDACT_AWK));
|
|
824
|
+
}
|
|
825
|
+
await backend.writeFile(sandboxId, `${dir}/runner.sh`, Buffer.from(RUNNER));
|
|
826
|
+
const launched = await backend.run(sandboxId, [
|
|
827
|
+
`set -e`,
|
|
828
|
+
`cd ${q3(dir)}`,
|
|
829
|
+
`chmod 600 env.sh`,
|
|
830
|
+
`[ ! -e started ] || { echo "job already started" >&2; exit 17; }`,
|
|
831
|
+
`${NOW} > started`,
|
|
832
|
+
`nohup setsid sh runner.sh ${q3(dir)} ${seconds} ${q3(input.cwd)} > /dev/null 2>&1 < /dev/null &`,
|
|
833
|
+
`i=0; until [ -s runner.pid ]; do i=$((i+1)); [ $i -le 100 ] || { echo "the job runner did not start" >&2; exit 1; }; sleep 0.1; done`,
|
|
834
|
+
`echo started`
|
|
835
|
+
].join(`
|
|
836
|
+
`), { timeoutMs: 30000 });
|
|
837
|
+
if (launched.exitCode !== 0)
|
|
838
|
+
throw new Error(`Could not start the job in the sandbox (exit ${launched.exitCode})`);
|
|
839
|
+
}
|
|
840
|
+
async function readJobState(backend, sandboxId, id) {
|
|
841
|
+
const dir = jobDir(backend, id);
|
|
842
|
+
const result = await backend.run(sandboxId, [
|
|
843
|
+
LIVENESS,
|
|
844
|
+
`J=${q3(dir)}`,
|
|
845
|
+
`[ -e "$J/started" ] || { echo missing=1; exit 0; }`,
|
|
846
|
+
`echo "started=$(cat "$J/started")"`,
|
|
847
|
+
`echo "finished=$(cat "$J/finished" 2>/dev/null)"`,
|
|
848
|
+
`echo "exit=$(cat "$J/exit" 2>/dev/null)"`,
|
|
849
|
+
`[ ! -e "$J/cancelled" ] || echo cancelled=1`,
|
|
850
|
+
`[ ! -e "$J/timed_out" ] || echo timed_out=1`,
|
|
851
|
+
`P=$(cat "$J/pgid" 2>/dev/null || true)`,
|
|
852
|
+
`[ -z "$P" ] || ! group_alive "$P" || echo group_alive=1`,
|
|
853
|
+
`R=$(cat "$J/runner.pid" 2>/dev/null || true)`,
|
|
854
|
+
`[ -z "$R" ] || ! pid_alive "$R" || echo runner_alive=1`,
|
|
855
|
+
`echo "log_bytes=$(wc -c < "$J/output.log" 2>/dev/null || echo 0)"`
|
|
856
|
+
].join(`
|
|
857
|
+
`), { timeoutMs: 30000 });
|
|
858
|
+
if (result.exitCode !== 0)
|
|
859
|
+
throw new Error("Could not read job state from the sandbox");
|
|
860
|
+
const values = Object.fromEntries(result.stdout.split(`
|
|
861
|
+
`).filter((line) => line.includes("=")).map((line) => [line.slice(0, line.indexOf("=")), line.slice(line.indexOf("=") + 1).trim()]));
|
|
862
|
+
if (values.missing)
|
|
863
|
+
return null;
|
|
864
|
+
const exitCode = values.exit ? Number(values.exit) : null;
|
|
865
|
+
const status = exitCode !== null ? values.cancelled ? "cancelled" : values.timed_out ? "timed_out" : exitCode === 0 ? "succeeded" : "failed" : values.runner_alive ? "running" : "lost";
|
|
866
|
+
return {
|
|
867
|
+
id,
|
|
868
|
+
status,
|
|
869
|
+
exitCode,
|
|
870
|
+
startedAt: values.started || null,
|
|
871
|
+
finishedAt: values.finished || null,
|
|
872
|
+
processAlive: Boolean(values.group_alive),
|
|
873
|
+
logBytes: Number(values.log_bytes) || 0
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
async function readJobLog(backend, sandboxId, id, offset, limit) {
|
|
877
|
+
if (!Number.isSafeInteger(offset) || offset < 0)
|
|
878
|
+
throw new Error("Invalid log offset");
|
|
879
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1024 * 1024)
|
|
880
|
+
throw new Error("Log reads are limited to 1 MiB");
|
|
881
|
+
const result = await backend.run(sandboxId, `tail -c +${offset + 1} ${q3(`${jobDir(backend, id)}/output.log`)} 2>/dev/null | head -c ${limit} | base64 | tr -d '\\n'`, { timeoutMs: 30000 });
|
|
882
|
+
if (result.exitCode !== 0)
|
|
883
|
+
throw new Error("Could not read the job log");
|
|
884
|
+
return Buffer.from(result.stdout.trim(), "base64");
|
|
885
|
+
}
|
|
886
|
+
async function cancelJob(backend, sandboxId, id, graceSeconds = 5) {
|
|
887
|
+
const dir = jobDir(backend, id);
|
|
888
|
+
const result = await backend.run(sandboxId, [
|
|
889
|
+
LIVENESS,
|
|
890
|
+
`J=${q3(dir)}`,
|
|
891
|
+
`[ -e "$J/started" ] || { echo "unknown job" >&2; exit 3; }`,
|
|
892
|
+
`P=$(cat "$J/pgid" 2>/dev/null || true)`,
|
|
893
|
+
`if [ -e "$J/exit" ]; then [ -n "$P" ] && group_alive "$P" || exit 0; else : > "$J/cancelled"; fi`,
|
|
894
|
+
`[ -n "$P" ] || exit 0`,
|
|
895
|
+
`kill -TERM -"$P" 2>/dev/null || true`,
|
|
896
|
+
`i=0; while [ $i -lt ${graceSeconds * 10} ] && group_alive "$P"; do sleep 0.1; i=$((i+1)); done`,
|
|
897
|
+
`kill -KILL -"$P" 2>/dev/null || true`,
|
|
898
|
+
`i=0; while [ $i -lt 50 ] && [ ! -e "$J/exit" ]; do sleep 0.1; i=$((i+1)); done`
|
|
899
|
+
].join(`
|
|
900
|
+
`), { timeoutMs: (graceSeconds + 15) * 1000 });
|
|
901
|
+
if (result.exitCode === 3)
|
|
902
|
+
throw new Error(`Unknown job ${id}`);
|
|
903
|
+
if (result.exitCode !== 0)
|
|
904
|
+
throw new Error("Could not cancel the job");
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// src/sandbox/setup-snapshots.ts
|
|
908
|
+
import { createHash as createHash5 } from "node:crypto";
|
|
909
|
+
import { mkdir as mkdir3, readdir as readdir2, readFile as readFile4, rm as rm3, writeFile as writeFile3 } from "node:fs/promises";
|
|
910
|
+
import path4 from "node:path";
|
|
911
|
+
|
|
912
|
+
// src/sandbox/store.ts
|
|
913
|
+
import { createHash as createHash4, randomBytes as randomBytes4 } from "node:crypto";
|
|
914
|
+
import { mkdir as mkdir2, readdir, readFile as readFile3, rename as rename2, rm as rm2, stat, writeFile as writeFile2 } from "node:fs/promises";
|
|
915
|
+
import { homedir as homedir2 } from "node:os";
|
|
916
|
+
import path3 from "node:path";
|
|
917
|
+
function defaultStoreDirectory() {
|
|
918
|
+
const base = process.env.XDG_STATE_HOME || path3.join(homedir2(), ".local", "state");
|
|
919
|
+
return path3.join(base, "gitterm", "sandboxes");
|
|
920
|
+
}
|
|
921
|
+
var fileName = (ref) => `${ref.mode}-${ref.provider}-${ref.account}-${ref.id}`.replace(/[^A-Za-z0-9_.-]/g, "_") + ".json";
|
|
922
|
+
var snapshotFile = (id, root) => `${id.replace(/[^A-Za-z0-9_]/g, "")}-${createHash4("sha256").update(root).digest("hex").slice(0, 16)}.json`;
|
|
923
|
+
var LOCK_STALE_MS = 30000;
|
|
924
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
925
|
+
async function withFileLock(target, action) {
|
|
926
|
+
const lock = `${target}.lock`;
|
|
927
|
+
const deadline = Date.now() + 2 * LOCK_STALE_MS;
|
|
928
|
+
while (true) {
|
|
929
|
+
try {
|
|
930
|
+
await writeFile2(lock, String(process.pid), { flag: "wx", mode: 384 });
|
|
931
|
+
break;
|
|
932
|
+
} catch (error) {
|
|
933
|
+
if (error.code !== "EEXIST")
|
|
934
|
+
throw error;
|
|
935
|
+
const age = await stat(lock).then((info) => Date.now() - info.mtimeMs, () => 0);
|
|
936
|
+
if (age > LOCK_STALE_MS)
|
|
937
|
+
await rm2(lock, { force: true });
|
|
938
|
+
else if (Date.now() > deadline)
|
|
939
|
+
throw new Error(`Timed out waiting for ${lock}`);
|
|
940
|
+
else
|
|
941
|
+
await sleep(5 + Math.random() * 20);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
try {
|
|
945
|
+
return await action();
|
|
946
|
+
} finally {
|
|
947
|
+
await rm2(lock, { force: true });
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
function createFileSandboxStore(directory = defaultStoreDirectory()) {
|
|
951
|
+
const read = async (file) => JSON.parse(await readFile3(path3.join(directory, file), "utf8"));
|
|
952
|
+
const write = async (target, record) => {
|
|
953
|
+
const temporary = `${target}.${process.pid}.${randomBytes4(6).toString("hex")}.tmp`;
|
|
954
|
+
try {
|
|
955
|
+
await writeFile2(temporary, JSON.stringify(record, null, 2), { mode: 384 });
|
|
956
|
+
await rename2(temporary, target);
|
|
957
|
+
} finally {
|
|
958
|
+
await rm2(temporary, { force: true });
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
const queues = new Map;
|
|
962
|
+
const serialized = (target, action) => {
|
|
963
|
+
const next = (queues.get(target) ?? Promise.resolve()).catch(() => {}).then(() => withFileLock(target, action));
|
|
964
|
+
queues.set(target, next);
|
|
965
|
+
next.then(() => queues.get(target) === next && queues.delete(target), () => queues.get(target) === next && queues.delete(target));
|
|
966
|
+
return next;
|
|
967
|
+
};
|
|
968
|
+
return {
|
|
969
|
+
async list() {
|
|
970
|
+
const files = await readdir(directory).catch(() => []);
|
|
971
|
+
const records = await Promise.all(files.filter((file) => file.endsWith(".json")).map((file) => read(file).catch(() => null)));
|
|
972
|
+
return records.filter((record) => record !== null);
|
|
973
|
+
},
|
|
974
|
+
async get(ref) {
|
|
975
|
+
return read(fileName(ref)).catch(() => null);
|
|
976
|
+
},
|
|
977
|
+
async put(record) {
|
|
978
|
+
await mkdir2(directory, { recursive: true, mode: 448 });
|
|
979
|
+
const target = path3.join(directory, fileName(record));
|
|
980
|
+
await serialized(target, () => write(target, record));
|
|
981
|
+
},
|
|
982
|
+
async update(ref, mutate, create) {
|
|
983
|
+
await mkdir2(directory, { recursive: true, mode: 448 });
|
|
984
|
+
const target = path3.join(directory, fileName(ref));
|
|
985
|
+
return serialized(target, async () => {
|
|
986
|
+
const current = await read(fileName(ref)).catch(() => null) ?? create?.() ?? null;
|
|
987
|
+
if (!current)
|
|
988
|
+
return null;
|
|
989
|
+
mutate(current);
|
|
990
|
+
current.updatedAt = new Date().toISOString();
|
|
991
|
+
await write(target, current);
|
|
992
|
+
return current;
|
|
993
|
+
});
|
|
994
|
+
},
|
|
995
|
+
async remove(ref) {
|
|
996
|
+
await rm2(path3.join(directory, fileName(ref)), { force: true });
|
|
997
|
+
},
|
|
998
|
+
async putSnapshot(snapshot) {
|
|
999
|
+
const target = path3.join(directory, "snapshots", snapshotFile(snapshot.id, snapshot.root));
|
|
1000
|
+
await mkdir2(path3.dirname(target), { recursive: true, mode: 448 });
|
|
1001
|
+
await writeFile2(target, JSON.stringify(snapshot), { mode: 384 });
|
|
1002
|
+
},
|
|
1003
|
+
async getSnapshot(id, root) {
|
|
1004
|
+
return readFile3(path3.join(directory, "snapshots", snapshotFile(id, root)), "utf8").then((text) => JSON.parse(text)).catch(() => null);
|
|
1005
|
+
}
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// src/sandbox/setup-snapshots.ts
|
|
1010
|
+
var SETUP_FILES = [
|
|
1011
|
+
"package.json",
|
|
1012
|
+
"bun.lock",
|
|
1013
|
+
"bun.lockb",
|
|
1014
|
+
"package-lock.json",
|
|
1015
|
+
"pnpm-lock.yaml",
|
|
1016
|
+
"yarn.lock",
|
|
1017
|
+
".nvmrc",
|
|
1018
|
+
".node-version",
|
|
1019
|
+
".tool-versions",
|
|
1020
|
+
".python-version",
|
|
1021
|
+
"requirements.txt",
|
|
1022
|
+
"requirements-dev.txt",
|
|
1023
|
+
"pyproject.toml",
|
|
1024
|
+
"poetry.lock",
|
|
1025
|
+
"uv.lock",
|
|
1026
|
+
"Pipfile.lock",
|
|
1027
|
+
"Cargo.lock",
|
|
1028
|
+
"go.sum",
|
|
1029
|
+
"Gemfile.lock",
|
|
1030
|
+
"composer.lock",
|
|
1031
|
+
"mix.lock",
|
|
1032
|
+
"pubspec.lock",
|
|
1033
|
+
"Dockerfile",
|
|
1034
|
+
"docker-compose.yml",
|
|
1035
|
+
"compose.yaml"
|
|
1036
|
+
];
|
|
1037
|
+
async function setupFingerprint(project) {
|
|
1038
|
+
const entries = await Promise.all(SETUP_FILES.map(async (file) => {
|
|
1039
|
+
const data = await readFile4(path4.join(project, file)).catch(() => null);
|
|
1040
|
+
return data ? [file, createHash5("sha256").update(data).digest("hex").slice(0, 16)] : null;
|
|
1041
|
+
}));
|
|
1042
|
+
return Object.fromEntries(entries.filter((entry) => entry !== null));
|
|
1043
|
+
}
|
|
1044
|
+
function changedSetupFiles(saved, current) {
|
|
1045
|
+
return [...new Set([...Object.keys(saved), ...Object.keys(current)])].filter((file) => saved[file] !== current[file]).toSorted();
|
|
1046
|
+
}
|
|
1047
|
+
function setupSnapshotName(label, project) {
|
|
1048
|
+
const slug = (text) => text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 24);
|
|
1049
|
+
const scope = project ? `${slug(path4.basename(project)) || "project"}-${createHash5("sha256").update(project).digest("hex").slice(0, 8)}` : "shared";
|
|
1050
|
+
return `gitterm-setup-${scope}-${slug(label) || "setup"}-${Date.now().toString(36)}`;
|
|
1051
|
+
}
|
|
1052
|
+
function createFileSetupSnapshotStore(directory = path4.join(defaultStoreDirectory(), "setup-snapshots")) {
|
|
1053
|
+
const prefix = (identity) => `${identity.mode}-${identity.provider}-${identity.account}-`.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
1054
|
+
const file = (identity, snapshotId) => path4.join(directory, `${prefix(identity)}${createHash5("sha256").update(snapshotId).digest("hex").slice(0, 16)}.json`);
|
|
1055
|
+
return {
|
|
1056
|
+
async list(identity) {
|
|
1057
|
+
const names = (await readdir2(directory).catch(() => [])).filter((name) => name.startsWith(prefix(identity)) && name.endsWith(".json"));
|
|
1058
|
+
const records = await Promise.all(names.map((name) => readFile4(path4.join(directory, name), "utf8").then((text) => JSON.parse(text)).catch(() => null)));
|
|
1059
|
+
return records.filter((record) => record !== null).toSorted((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
1060
|
+
},
|
|
1061
|
+
async put(record) {
|
|
1062
|
+
await mkdir3(directory, { recursive: true, mode: 448 });
|
|
1063
|
+
await writeFile3(file(record, record.snapshotId), JSON.stringify(record, null, 2), {
|
|
1064
|
+
mode: 384
|
|
1065
|
+
});
|
|
1066
|
+
},
|
|
1067
|
+
async remove(identity, snapshotId) {
|
|
1068
|
+
await rm3(file(identity, snapshotId), { force: true });
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// src/sandbox/client.ts
|
|
1074
|
+
var DEFAULT_SANDBOX_LIMITS = {
|
|
1075
|
+
maxSandboxes: 2,
|
|
1076
|
+
maxLifetimeMinutes: 60,
|
|
1077
|
+
defaultLifetimeMinutes: 30,
|
|
1078
|
+
maxJobMinutes: 30
|
|
1079
|
+
};
|
|
1080
|
+
var DEFAULT_LOG_TAIL_BYTES = 8 * 1024;
|
|
1081
|
+
var sleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1082
|
+
function sanitizeOutput(text, secrets = []) {
|
|
1083
|
+
let clean = text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, "").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
|
|
1084
|
+
for (const secret of secrets)
|
|
1085
|
+
if (secret.length >= 4)
|
|
1086
|
+
clean = clean.replaceAll(secret, "[redacted]");
|
|
1087
|
+
return clean;
|
|
1088
|
+
}
|
|
1089
|
+
function resolveCwd(home, cwd) {
|
|
1090
|
+
if (!cwd)
|
|
1091
|
+
return projectDir(home);
|
|
1092
|
+
const resolved = cwd.startsWith("/") ? path5.posix.normalize(cwd) : path5.posix.join(projectDir(home), cwd);
|
|
1093
|
+
if (resolved.split("/").includes(".."))
|
|
1094
|
+
throw new Error("Invalid working directory");
|
|
1095
|
+
return resolved;
|
|
1096
|
+
}
|
|
1097
|
+
function createSandboxClient(options) {
|
|
1098
|
+
const { backend } = options;
|
|
1099
|
+
const identity = backend.identity;
|
|
1100
|
+
const store = options.store ?? createFileSandboxStore();
|
|
1101
|
+
const setupSnapshots = options.setupSnapshots ?? createFileSetupSnapshotStore();
|
|
1102
|
+
const limits = { ...DEFAULT_SANDBOX_LIMITS, ...options.limits };
|
|
1103
|
+
const secrets = new Set;
|
|
1104
|
+
function assertRef(ref) {
|
|
1105
|
+
if (ref.mode !== identity.mode || ref.provider !== identity.provider || ref.account !== identity.account)
|
|
1106
|
+
throw new SandboxAccountError(`Sandbox ${ref.id} belongs to ${ref.mode} account ${JSON.stringify(ref.account)} (${ref.provider}), but ${identity.mode} account ${JSON.stringify(identity.account)} is selected. Select that account; Gitterm will not switch accounts automatically.`);
|
|
1107
|
+
}
|
|
1108
|
+
const refOf = (id) => ({ ...identity, id });
|
|
1109
|
+
function recordFrom(info, existing) {
|
|
1110
|
+
return {
|
|
1111
|
+
...existing ?? {
|
|
1112
|
+
name: info.name ?? info.id,
|
|
1113
|
+
purpose: info.labels.gitterm_purpose ?? "",
|
|
1114
|
+
project: null,
|
|
1115
|
+
sessionId: null,
|
|
1116
|
+
publicPreviews: false,
|
|
1117
|
+
createdAt: info.createdAt ?? new Date().toISOString(),
|
|
1118
|
+
snapshot: null,
|
|
1119
|
+
jobs: {},
|
|
1120
|
+
previews: {}
|
|
1121
|
+
},
|
|
1122
|
+
...refOf(info.id),
|
|
1123
|
+
state: info.state,
|
|
1124
|
+
expiresAt: info.expiresAt ?? existing?.expiresAt ?? null,
|
|
1125
|
+
updatedAt: new Date().toISOString()
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
const applyInfo = (info) => (record) => {
|
|
1129
|
+
record.state = info.state;
|
|
1130
|
+
record.expiresAt = info.expiresAt ?? record.expiresAt;
|
|
1131
|
+
};
|
|
1132
|
+
async function load(ref) {
|
|
1133
|
+
assertRef(ref);
|
|
1134
|
+
const info = await backend.get(ref.id);
|
|
1135
|
+
const stored = await store.update(ref, applyInfo(info), () => info.state === "terminated" ? null : recordFrom(info, null));
|
|
1136
|
+
return stored ?? recordFrom(info, null);
|
|
1137
|
+
}
|
|
1138
|
+
async function resumeSandbox(ref, resume) {
|
|
1139
|
+
await resume(ref.id);
|
|
1140
|
+
const record = await settled(ref);
|
|
1141
|
+
if (record.state !== "running" || backend.capabilities.pause !== "state-losing")
|
|
1142
|
+
return { record, filesLost: false };
|
|
1143
|
+
const cleared = await store.update(ref, (stored) => {
|
|
1144
|
+
stored.snapshot = null;
|
|
1145
|
+
});
|
|
1146
|
+
return { record: cleared ?? record, filesLost: true };
|
|
1147
|
+
}
|
|
1148
|
+
async function settled(ref) {
|
|
1149
|
+
const deadline = Date.now() + 120000;
|
|
1150
|
+
let record = await load(ref);
|
|
1151
|
+
while (record.state === "pending" && Date.now() < deadline) {
|
|
1152
|
+
await sleep2(1000);
|
|
1153
|
+
record = await load(ref);
|
|
1154
|
+
}
|
|
1155
|
+
return record;
|
|
1156
|
+
}
|
|
1157
|
+
async function running(ref, use = "other") {
|
|
1158
|
+
let record = await settled(ref);
|
|
1159
|
+
if (record.state === "paused" && backend.resume) {
|
|
1160
|
+
const resumed = await resumeSandbox(ref, backend.resume);
|
|
1161
|
+
record = resumed.record;
|
|
1162
|
+
if (resumed.filesLost && use !== "sync")
|
|
1163
|
+
throw new Error(`Sandbox ${ref.id} was paused and is running again, but ${ref.provider} discards files and processes on pause. Sync again, then retry; earlier jobs cannot be reattached.`);
|
|
1164
|
+
}
|
|
1165
|
+
if (record.state === "terminated")
|
|
1166
|
+
throw new SandboxProviderError("not_found", `Sandbox ${ref.id} no longer exists (deleted or expired).`);
|
|
1167
|
+
if (record.state !== "running")
|
|
1168
|
+
throw new Error(`Sandbox ${ref.id} is ${record.state}.`);
|
|
1169
|
+
return record;
|
|
1170
|
+
}
|
|
1171
|
+
async function view(record, jobId, read) {
|
|
1172
|
+
const job = record.jobs[jobId];
|
|
1173
|
+
if (!job)
|
|
1174
|
+
throw new Error(`Unknown job ${jobId} for sandbox ${record.id}`);
|
|
1175
|
+
const state = await readJobState(backend, record.id, jobId);
|
|
1176
|
+
if (!state)
|
|
1177
|
+
throw new Error(`Job ${jobId} was not found in the sandbox`);
|
|
1178
|
+
const limit = Math.min(read.limit ?? read.tailBytes ?? DEFAULT_LOG_TAIL_BYTES, 1024 * 1024);
|
|
1179
|
+
const offset = read.offset ?? Math.max(0, state.logBytes - limit);
|
|
1180
|
+
const bytes = state.logBytes > offset ? await readJobLog(backend, record.id, jobId, offset, limit) : Buffer.alloc(0);
|
|
1181
|
+
const progress = {
|
|
1182
|
+
status: state.status,
|
|
1183
|
+
exitCode: state.exitCode,
|
|
1184
|
+
startedAt: state.startedAt,
|
|
1185
|
+
finishedAt: state.finishedAt
|
|
1186
|
+
};
|
|
1187
|
+
Object.assign(job, progress);
|
|
1188
|
+
await store.update(record, (stored) => {
|
|
1189
|
+
if (stored.jobs[jobId])
|
|
1190
|
+
Object.assign(stored.jobs[jobId], progress);
|
|
1191
|
+
});
|
|
1192
|
+
return {
|
|
1193
|
+
job,
|
|
1194
|
+
state,
|
|
1195
|
+
log: {
|
|
1196
|
+
text: sanitizeOutput(bytes.toString("utf8"), [...secrets]),
|
|
1197
|
+
offset,
|
|
1198
|
+
nextOffset: offset + bytes.byteLength,
|
|
1199
|
+
totalBytes: state.logBytes,
|
|
1200
|
+
truncated: offset > 0 || offset + bytes.byteLength < state.logBytes
|
|
1201
|
+
}
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
async function launchJob(ref, input, maxMinutes) {
|
|
1205
|
+
const record = await running(ref);
|
|
1206
|
+
const timeoutMinutes = input.timeoutMinutes ?? Math.min(10, maxMinutes);
|
|
1207
|
+
if (!(timeoutMinutes > 0) || timeoutMinutes > maxMinutes)
|
|
1208
|
+
throw new Error(`Job timeout must be between 0 and ${maxMinutes} minutes`);
|
|
1209
|
+
assertRedactableSecrets(input.env);
|
|
1210
|
+
for (const value of Object.values(input.env ?? {}))
|
|
1211
|
+
secrets.add(value);
|
|
1212
|
+
const id = newJobId();
|
|
1213
|
+
const cwd = resolveCwd(backend.home, input.cwd);
|
|
1214
|
+
const job = {
|
|
1215
|
+
id,
|
|
1216
|
+
command: input.command,
|
|
1217
|
+
cwd,
|
|
1218
|
+
snapshotId: record.snapshot?.id ?? null,
|
|
1219
|
+
snapshotDigest: record.snapshot?.digest ?? null,
|
|
1220
|
+
snapshotRoot: record.snapshot?.root ?? null,
|
|
1221
|
+
status: "running",
|
|
1222
|
+
exitCode: null,
|
|
1223
|
+
startedAt: null,
|
|
1224
|
+
finishedAt: null,
|
|
1225
|
+
timeoutMs: Math.round(timeoutMinutes * 60000),
|
|
1226
|
+
secretNames: Object.keys(input.env ?? {}),
|
|
1227
|
+
sessionId: input.sessionId ?? null
|
|
1228
|
+
};
|
|
1229
|
+
await store.update(record, (stored) => {
|
|
1230
|
+
stored.jobs[id] = job;
|
|
1231
|
+
});
|
|
1232
|
+
await startJob(backend, ref.id, id, {
|
|
1233
|
+
command: input.command,
|
|
1234
|
+
cwd,
|
|
1235
|
+
timeoutMs: job.timeoutMs,
|
|
1236
|
+
env: input.env
|
|
1237
|
+
});
|
|
1238
|
+
return api.wait(ref, id, input.waitMs ?? 0);
|
|
1239
|
+
}
|
|
1240
|
+
const api = {
|
|
1241
|
+
identity,
|
|
1242
|
+
home: backend.home,
|
|
1243
|
+
capabilities: backend.capabilities,
|
|
1244
|
+
limits,
|
|
1245
|
+
store,
|
|
1246
|
+
onJobFinished: backend.onJobFinished?.bind(backend),
|
|
1247
|
+
async create(input) {
|
|
1248
|
+
const lifetimeMinutes = input.lifetimeMinutes ?? limits.defaultLifetimeMinutes;
|
|
1249
|
+
if (!Number.isInteger(lifetimeMinutes) || lifetimeMinutes < 1)
|
|
1250
|
+
throw new Error("Lifetime must be a whole number of minutes");
|
|
1251
|
+
if (lifetimeMinutes > limits.maxLifetimeMinutes)
|
|
1252
|
+
throw new Error(`Lifetime ${lifetimeMinutes} min exceeds the approved limit of ${limits.maxLifetimeMinutes} min`);
|
|
1253
|
+
const live = (await backend.list()).filter((item) => item.state !== "terminated");
|
|
1254
|
+
if (live.length >= limits.maxSandboxes)
|
|
1255
|
+
throw new Error(`Account ${identity.account} already has ${live.length} live sandbox(es); the approved limit is ${limits.maxSandboxes}. Destroy one first.`);
|
|
1256
|
+
const info = await backend.create({
|
|
1257
|
+
name: input.name,
|
|
1258
|
+
lifetimeMs: lifetimeMinutes * 60000,
|
|
1259
|
+
publicPreviews: input.publicPreviews === true,
|
|
1260
|
+
...input.snapshot ? { snapshot: input.snapshot } : {},
|
|
1261
|
+
labels: {
|
|
1262
|
+
...input.purpose ? { gitterm_purpose: input.purpose.slice(0, 120) } : {},
|
|
1263
|
+
...input.sessionId ? { gitterm_session: input.sessionId.slice(0, 120) } : {}
|
|
1264
|
+
}
|
|
1265
|
+
});
|
|
1266
|
+
const record = {
|
|
1267
|
+
...recordFrom(info, null),
|
|
1268
|
+
name: input.name,
|
|
1269
|
+
purpose: input.purpose ?? "",
|
|
1270
|
+
project: input.project ?? null,
|
|
1271
|
+
sessionId: input.sessionId ?? null,
|
|
1272
|
+
publicPreviews: input.publicPreviews === true,
|
|
1273
|
+
createdAt: info.createdAt ?? new Date().toISOString()
|
|
1274
|
+
};
|
|
1275
|
+
try {
|
|
1276
|
+
await store.put(record);
|
|
1277
|
+
const prepared = await backend.run(info.id, `mkdir -p '${projectDir(backend.home)}'`, {
|
|
1278
|
+
timeoutMs: 30000
|
|
1279
|
+
});
|
|
1280
|
+
if (prepared.exitCode !== 0)
|
|
1281
|
+
throw new Error("Could not prepare the sandbox project directory");
|
|
1282
|
+
} catch (error) {
|
|
1283
|
+
await backend.destroy(info.id).catch(() => {});
|
|
1284
|
+
await store.remove(record).catch(() => {});
|
|
1285
|
+
throw error;
|
|
1286
|
+
}
|
|
1287
|
+
return record;
|
|
1288
|
+
},
|
|
1289
|
+
async list() {
|
|
1290
|
+
const remote = new Map((await backend.list()).map((item) => [item.id, item]));
|
|
1291
|
+
const records = [];
|
|
1292
|
+
for (const record of await store.list()) {
|
|
1293
|
+
if (record.mode !== identity.mode || record.provider !== identity.provider || record.account !== identity.account)
|
|
1294
|
+
continue;
|
|
1295
|
+
const info = remote.get(record.id) ?? (record.state === "terminated" ? null : await backend.get(record.id));
|
|
1296
|
+
remote.delete(record.id);
|
|
1297
|
+
records.push(info ? await store.update(record, applyInfo(info)) ?? record : record);
|
|
1298
|
+
}
|
|
1299
|
+
for (const info of remote.values()) {
|
|
1300
|
+
const record = recordFrom(info, null);
|
|
1301
|
+
records.push(await store.update(record, applyInfo(info), () => record) ?? record);
|
|
1302
|
+
}
|
|
1303
|
+
return records;
|
|
1304
|
+
},
|
|
1305
|
+
get: load,
|
|
1306
|
+
async sync(ref, captured) {
|
|
1307
|
+
const record = await running(ref, "sync");
|
|
1308
|
+
await store.putSnapshot(captured.snapshot);
|
|
1309
|
+
const result = await uploadSource(backend, ref.id, captured);
|
|
1310
|
+
await store.update(record, (stored) => {
|
|
1311
|
+
stored.snapshot = {
|
|
1312
|
+
id: captured.snapshot.id,
|
|
1313
|
+
digest: result.digest,
|
|
1314
|
+
root: captured.snapshot.root,
|
|
1315
|
+
syncedAt: new Date().toISOString()
|
|
1316
|
+
};
|
|
1317
|
+
});
|
|
1318
|
+
return { snapshot: captured.snapshot, verified: result.verified };
|
|
1319
|
+
},
|
|
1320
|
+
exec: (ref, input) => launchJob(ref, input, limits.maxJobMinutes),
|
|
1321
|
+
async wait(ref, jobId, waitMs, read = {}) {
|
|
1322
|
+
const deadline = Date.now() + Math.max(0, waitMs);
|
|
1323
|
+
while (true) {
|
|
1324
|
+
const result = await view(await running(ref), jobId, read);
|
|
1325
|
+
if (result.state.status !== "running" || Date.now() >= deadline)
|
|
1326
|
+
return result;
|
|
1327
|
+
await sleep2(Math.min(2000, Math.max(250, deadline - Date.now())));
|
|
1328
|
+
}
|
|
1329
|
+
},
|
|
1330
|
+
async job(ref, jobId, read = {}) {
|
|
1331
|
+
return view(await running(ref), jobId, read);
|
|
1332
|
+
},
|
|
1333
|
+
async cancel(ref, jobId) {
|
|
1334
|
+
const record = await running(ref);
|
|
1335
|
+
if (!record.jobs[jobId])
|
|
1336
|
+
throw new Error(`Unknown job ${jobId} for sandbox ${ref.id}`);
|
|
1337
|
+
await cancelJob(backend, ref.id, jobId);
|
|
1338
|
+
return view(record, jobId, {});
|
|
1339
|
+
},
|
|
1340
|
+
async preview(ref, input) {
|
|
1341
|
+
const record = await running(ref);
|
|
1342
|
+
if (backend.capabilities.previews === "none")
|
|
1343
|
+
throw new Error(`${ref.provider} sandboxes cannot expose ports.`);
|
|
1344
|
+
if (backend.capabilities.previews === "public" && !record.publicPreviews)
|
|
1345
|
+
throw new Error(`${ref.provider} previews are reachable by anyone with the URL. Create the sandbox with publicPreviews to approve that.`);
|
|
1346
|
+
let jobId = null;
|
|
1347
|
+
if (input.command) {
|
|
1348
|
+
const minutes = Math.max(limits.maxJobMinutes, record.expiresAt ? (Date.parse(record.expiresAt) - Date.now()) / 60000 : 0);
|
|
1349
|
+
jobId = (await launchJob(ref, { command: input.command, cwd: input.cwd, timeoutMinutes: minutes }, minutes)).job.id;
|
|
1350
|
+
}
|
|
1351
|
+
const { gittermUrl, ...access } = await backend.preview(ref.id, input.port, {
|
|
1352
|
+
public: record.publicPreviews
|
|
1353
|
+
});
|
|
1354
|
+
const url = gittermUrl ?? access.url;
|
|
1355
|
+
const isPublic = backend.capabilities.previews === "public" || backend.capabilities.previews === "either" && record.publicPreviews;
|
|
1356
|
+
const deadline = Date.now() + (input.readyTimeoutMs ?? 60000);
|
|
1357
|
+
let ready = false;
|
|
1358
|
+
while (!ready && Date.now() < deadline) {
|
|
1359
|
+
const status = await fetch(access.url, {
|
|
1360
|
+
headers: access.headers,
|
|
1361
|
+
redirect: "manual",
|
|
1362
|
+
signal: AbortSignal.timeout(5000)
|
|
1363
|
+
}).then(async (response) => {
|
|
1364
|
+
await response.body?.cancel();
|
|
1365
|
+
return response.status;
|
|
1366
|
+
}).catch(() => 0);
|
|
1367
|
+
ready = status > 0 && ![502, 503, 504].includes(status);
|
|
1368
|
+
if (!ready) {
|
|
1369
|
+
if (jobId) {
|
|
1370
|
+
const state = await readJobState(backend, ref.id, jobId);
|
|
1371
|
+
if (state && state.status !== "running")
|
|
1372
|
+
throw new Error(`Preview command exited (${state.status}, exit ${state.exitCode}) before port ${input.port} answered. Read job ${jobId} logs.`);
|
|
1373
|
+
}
|
|
1374
|
+
await sleep2(1000);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
const unauthenticatedStatus = await fetch(url, {
|
|
1378
|
+
redirect: "manual",
|
|
1379
|
+
signal: AbortSignal.timeout(5000)
|
|
1380
|
+
}).then(async (response) => {
|
|
1381
|
+
await response.body?.cancel();
|
|
1382
|
+
return response.status;
|
|
1383
|
+
}).catch(() => null);
|
|
1384
|
+
await store.update(record, (stored) => {
|
|
1385
|
+
stored.previews[String(input.port)] = {
|
|
1386
|
+
port: input.port,
|
|
1387
|
+
jobId,
|
|
1388
|
+
url,
|
|
1389
|
+
public: isPublic
|
|
1390
|
+
};
|
|
1391
|
+
});
|
|
1392
|
+
return {
|
|
1393
|
+
url,
|
|
1394
|
+
port: input.port,
|
|
1395
|
+
public: isPublic,
|
|
1396
|
+
viaGitterm: Boolean(gittermUrl),
|
|
1397
|
+
access,
|
|
1398
|
+
ready,
|
|
1399
|
+
unauthenticatedStatus,
|
|
1400
|
+
jobId
|
|
1401
|
+
};
|
|
1402
|
+
},
|
|
1403
|
+
async fetchArtifact(ref, file, producedBy) {
|
|
1404
|
+
const record = await running(ref);
|
|
1405
|
+
if (!record.jobs[producedBy.jobId])
|
|
1406
|
+
throw new Error(`Unknown job ${producedBy.jobId} for sandbox ${ref.id}`);
|
|
1407
|
+
const artifact = await fetchArtifact(backend, ref.id, file);
|
|
1408
|
+
await store.update(record, (stored) => {
|
|
1409
|
+
stored.artifacts = {
|
|
1410
|
+
...stored.artifacts,
|
|
1411
|
+
[artifact.localPath]: {
|
|
1412
|
+
localPath: artifact.localPath,
|
|
1413
|
+
remotePath: artifact.remotePath,
|
|
1414
|
+
sha256: artifact.sha256,
|
|
1415
|
+
bytes: artifact.bytes,
|
|
1416
|
+
jobId: producedBy.jobId,
|
|
1417
|
+
fetchedAt: new Date().toISOString()
|
|
1418
|
+
}
|
|
1419
|
+
};
|
|
1420
|
+
});
|
|
1421
|
+
return { ...artifact, jobId: producedBy.jobId };
|
|
1422
|
+
},
|
|
1423
|
+
async applyArtifact(ref, input) {
|
|
1424
|
+
assertRef(ref);
|
|
1425
|
+
const record = await store.get(ref);
|
|
1426
|
+
const artifact = record?.artifacts?.[input.localPath];
|
|
1427
|
+
if (!record || !artifact)
|
|
1428
|
+
throw new Error("Unknown artifact. Fetch it with the job that produced it first.");
|
|
1429
|
+
const job = record.jobs[artifact.jobId];
|
|
1430
|
+
if (!job?.snapshotId || !job.snapshotRoot)
|
|
1431
|
+
throw new Error(`Job ${artifact.jobId} did not run on a synced snapshot, so there is nothing to check the checkout against.`);
|
|
1432
|
+
const snapshot = await store.getSnapshot(job.snapshotId, job.snapshotRoot);
|
|
1433
|
+
if (!snapshot)
|
|
1434
|
+
throw new Error(`Snapshot ${job.snapshotId} manifest is not available locally.`);
|
|
1435
|
+
await input.beforeWrite?.({
|
|
1436
|
+
root: snapshot.root,
|
|
1437
|
+
target: input.target,
|
|
1438
|
+
sha256: artifact.sha256,
|
|
1439
|
+
jobId: job.id,
|
|
1440
|
+
snapshotId: snapshot.id
|
|
1441
|
+
});
|
|
1442
|
+
return {
|
|
1443
|
+
...await applyArtifact({ artifact, snapshot, target: input.target }),
|
|
1444
|
+
root: snapshot.root,
|
|
1445
|
+
snapshotId: snapshot.id,
|
|
1446
|
+
jobId: job.id
|
|
1447
|
+
};
|
|
1448
|
+
},
|
|
1449
|
+
async resume(ref) {
|
|
1450
|
+
assertRef(ref);
|
|
1451
|
+
if (!backend.resume)
|
|
1452
|
+
throw new Error("Only managed sandboxes pause, so direct sandboxes cannot be resumed.");
|
|
1453
|
+
return (await resumeSandbox(ref, backend.resume)).record;
|
|
1454
|
+
},
|
|
1455
|
+
snapshotEffect: backend.snapshots?.effect ?? null,
|
|
1456
|
+
async setupSnapshots(project) {
|
|
1457
|
+
const records = (await setupSnapshots.list(identity)).filter((record) => !project || record.project === null || record.project === project);
|
|
1458
|
+
return Promise.all(records.map(async (record) => ({
|
|
1459
|
+
record,
|
|
1460
|
+
changed: record.project ? changedSetupFiles(record.fingerprint, await setupFingerprint(record.project)) : []
|
|
1461
|
+
})));
|
|
1462
|
+
},
|
|
1463
|
+
async saveSetupSnapshot(ref, input) {
|
|
1464
|
+
assertRef(ref);
|
|
1465
|
+
if (!backend.snapshots)
|
|
1466
|
+
throw new Error(`${identity.mode === "managed" ? "Managed accounts" : `${identity.provider} sandboxes`} cannot be snapshotted.`);
|
|
1467
|
+
const previous = input.replace ? (await setupSnapshots.list(identity)).find((record) => record.snapshotId === input.replace) : undefined;
|
|
1468
|
+
if (input.replace && !previous)
|
|
1469
|
+
throw new Error(`No saved snapshot ${input.replace}.`);
|
|
1470
|
+
await running(ref);
|
|
1471
|
+
const project = input.project ?? null;
|
|
1472
|
+
const fingerprint = project ? await setupFingerprint(project) : {};
|
|
1473
|
+
const snapshotId = await backend.snapshots.create(ref.id, setupSnapshotName(input.label, project));
|
|
1474
|
+
const record = {
|
|
1475
|
+
...identity,
|
|
1476
|
+
snapshotId,
|
|
1477
|
+
label: input.label.slice(0, 60),
|
|
1478
|
+
project,
|
|
1479
|
+
note: input.note?.slice(0, 500) ?? "",
|
|
1480
|
+
fingerprint,
|
|
1481
|
+
sandboxId: ref.id,
|
|
1482
|
+
createdAt: new Date().toISOString()
|
|
1483
|
+
};
|
|
1484
|
+
await setupSnapshots.put(record);
|
|
1485
|
+
if (backend.snapshots.effect === "stops")
|
|
1486
|
+
await load(ref).catch(() => {});
|
|
1487
|
+
let leftover = null;
|
|
1488
|
+
if (previous && previous.snapshotId !== snapshotId) {
|
|
1489
|
+
await setupSnapshots.remove(identity, previous.snapshotId);
|
|
1490
|
+
await backend.snapshots.delete(previous.snapshotId).catch(() => {
|
|
1491
|
+
leftover = previous.snapshotId;
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
return { record, replaced: previous?.snapshotId ?? null, leftover };
|
|
1495
|
+
},
|
|
1496
|
+
async deleteSetupSnapshot(snapshotId) {
|
|
1497
|
+
const record = (await setupSnapshots.list(identity)).find((item) => item.snapshotId === snapshotId);
|
|
1498
|
+
if (!record)
|
|
1499
|
+
throw new Error(`No saved snapshot ${snapshotId}.`);
|
|
1500
|
+
if (!backend.snapshots)
|
|
1501
|
+
throw new Error("This account cannot delete snapshots.");
|
|
1502
|
+
await backend.snapshots.delete(snapshotId);
|
|
1503
|
+
await setupSnapshots.remove(identity, snapshotId);
|
|
1504
|
+
return record;
|
|
1505
|
+
},
|
|
1506
|
+
async destroy(ref) {
|
|
1507
|
+
assertRef(ref);
|
|
1508
|
+
await backend.destroy(ref.id);
|
|
1509
|
+
let info = await backend.get(ref.id);
|
|
1510
|
+
for (const deadline = Date.now() + 30000;info.state !== "terminated" && Date.now() < deadline; ) {
|
|
1511
|
+
await sleep2(2000);
|
|
1512
|
+
info = await backend.get(ref.id);
|
|
1513
|
+
}
|
|
1514
|
+
const record = await store.update(ref, applyInfo(info)) ?? recordFrom(info, null);
|
|
1515
|
+
if (info.state !== "terminated")
|
|
1516
|
+
throw new Error(`The provider still reports sandbox ${ref.id} as ${info.state}. Retry destroy.`);
|
|
1517
|
+
return record;
|
|
1518
|
+
}
|
|
1519
|
+
};
|
|
1520
|
+
return api;
|
|
1521
|
+
}
|
|
1522
|
+
// ../agent-runtime/src/sandbox/boat.ts
|
|
1523
|
+
import { randomBytes as randomBytes5 } from "node:crypto";
|
|
1524
|
+
import { BoxApi, Configuration, waitUntilReady } from "@asciidev/box-sdk";
|
|
1525
|
+
var profile = SANDBOX_PROVIDER_PROFILES.boat;
|
|
1526
|
+
var BASE_URL = "https://ascii.dev/api/box/v1";
|
|
1527
|
+
var NAME_PREFIX = "gitterm-sbx-";
|
|
1528
|
+
var WRITE_CHUNK_BYTES = 4 * 1024 * 1024;
|
|
1529
|
+
var failure = (error, action) => classifyFailure("Boat", error, action);
|
|
1530
|
+
function infoFrom(box) {
|
|
1531
|
+
const match = new RegExp(`^${NAME_PREFIX}([a-f0-9]{10})-`).exec(box.name ?? "");
|
|
1532
|
+
return {
|
|
1533
|
+
id: box.id,
|
|
1534
|
+
state: ["ready", "idle", "running"].includes(box.state) ? "running" : box.state === "archived" ? "paused" : box.state === "error" ? "terminated" : "pending",
|
|
1535
|
+
labels: match ? { gitterm_account_hash: match[1], gitterm_workload: "sandbox" } : {},
|
|
1536
|
+
startedAt: box.createdAt ? new Date(box.createdAt).toISOString() : null,
|
|
1537
|
+
expiresAt: box.archiveAfter ? new Date(box.archiveAfter).toISOString() : null
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
function createBoatSandboxTransport(config) {
|
|
1541
|
+
if (!config.apiKey?.trim())
|
|
1542
|
+
throw new SandboxProviderError("auth", "Boat apiKey is empty.");
|
|
1543
|
+
const client = new BoxApi(new Configuration({ basePath: BASE_URL, accessToken: config.apiKey }));
|
|
1544
|
+
const get = async (id) => {
|
|
1545
|
+
try {
|
|
1546
|
+
return (await client.get({ boxId: id })).box;
|
|
1547
|
+
} catch (error) {
|
|
1548
|
+
throw failure(error, "read the sandbox");
|
|
1549
|
+
}
|
|
1550
|
+
};
|
|
1551
|
+
const running = async (id) => {
|
|
1552
|
+
const box = await get(id);
|
|
1553
|
+
if (infoFrom(box).state !== "running")
|
|
1554
|
+
throw new SandboxProviderError("not_found", `The Boat box is ${box.state}.`);
|
|
1555
|
+
};
|
|
1556
|
+
async function command(id, script, timeoutMs = 60000) {
|
|
1557
|
+
try {
|
|
1558
|
+
const result = await client.command({
|
|
1559
|
+
boxId: id,
|
|
1560
|
+
commandRequest: {
|
|
1561
|
+
command: script,
|
|
1562
|
+
timeoutSeconds: Math.min(600, Math.max(1, Math.ceil(timeoutMs / 1000)))
|
|
1563
|
+
}
|
|
1564
|
+
});
|
|
1565
|
+
return {
|
|
1566
|
+
exitCode: result.timedOut ? 124 : result.exitCode ?? 1,
|
|
1567
|
+
stdout: result.stdout ?? "",
|
|
1568
|
+
stderr: result.stderr ?? ""
|
|
1569
|
+
};
|
|
1570
|
+
} catch (error) {
|
|
1571
|
+
throw failure(error, "run a command");
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
async function destroy(id) {
|
|
1575
|
+
const response = await fetch(`${BASE_URL}/boxes/${encodeURIComponent(id)}`, {
|
|
1576
|
+
method: "DELETE",
|
|
1577
|
+
headers: { Authorization: `Bearer ${config.apiKey}`, "X-Ascii-Confirm-Delete": id }
|
|
1578
|
+
}).catch((error) => {
|
|
1579
|
+
throw failure(error, "delete the sandbox");
|
|
1580
|
+
});
|
|
1581
|
+
if (response.status === 401 || response.status === 403)
|
|
1582
|
+
throw new SandboxProviderError("auth", "Boat rejected the API key while trying to delete the sandbox.");
|
|
1583
|
+
if (!response.ok && response.status !== 404)
|
|
1584
|
+
throw new SandboxProviderError("provider", `Boat failed to delete the sandbox (${response.status}).`);
|
|
1585
|
+
}
|
|
1586
|
+
return {
|
|
1587
|
+
provider: "boat",
|
|
1588
|
+
...profile,
|
|
1589
|
+
async create(input) {
|
|
1590
|
+
validateLifetime(input.timeoutMs, profile.capabilities.maxLifetimeMs);
|
|
1591
|
+
const account = input.labels.gitterm_account ?? input.labels.gitterm_workspace ?? "managed";
|
|
1592
|
+
const name = `${NAME_PREFIX}${accountHash(account)}-${randomBytes5(4).toString("hex")}`;
|
|
1593
|
+
let id;
|
|
1594
|
+
try {
|
|
1595
|
+
id = (await client.create({
|
|
1596
|
+
createBoxRequest: {
|
|
1597
|
+
type: input.machine?.size ?? config.size,
|
|
1598
|
+
ttlSeconds: Math.ceil(input.timeoutMs / 1000),
|
|
1599
|
+
noEnv: true,
|
|
1600
|
+
...input.snapshot ?? config.snapshot ? { from: input.snapshot ?? config.snapshot } : {},
|
|
1601
|
+
...input.env ? { env: input.env } : {}
|
|
1602
|
+
}
|
|
1603
|
+
})).box.id;
|
|
1604
|
+
} catch (error) {
|
|
1605
|
+
throw failure(error, "create a sandbox");
|
|
1606
|
+
}
|
|
1607
|
+
try {
|
|
1608
|
+
await waitUntilReady(client, id);
|
|
1609
|
+
await client.update({ boxId: id, updateBoxRequest: { name } });
|
|
1610
|
+
return infoFrom(await get(id));
|
|
1611
|
+
} catch (error) {
|
|
1612
|
+
return discardFailedCreate(destroy, id, failure(error, "prepare the sandbox"));
|
|
1613
|
+
}
|
|
1614
|
+
},
|
|
1615
|
+
async info(id) {
|
|
1616
|
+
try {
|
|
1617
|
+
return infoFrom((await client.get({ boxId: id })).box);
|
|
1618
|
+
} catch (error) {
|
|
1619
|
+
const classified = failure(error, "read the sandbox");
|
|
1620
|
+
if (classified.code === "not_found")
|
|
1621
|
+
return terminated(id);
|
|
1622
|
+
throw classified;
|
|
1623
|
+
}
|
|
1624
|
+
},
|
|
1625
|
+
async list(labels) {
|
|
1626
|
+
const items = [];
|
|
1627
|
+
let cursor;
|
|
1628
|
+
try {
|
|
1629
|
+
do {
|
|
1630
|
+
const page = await client.boxes({ limit: 100, cursor });
|
|
1631
|
+
for (const box of page.boxes) {
|
|
1632
|
+
const info = infoFrom(box);
|
|
1633
|
+
if (info.labels.gitterm_workload !== "sandbox" || info.state === "terminated")
|
|
1634
|
+
continue;
|
|
1635
|
+
const wanted = labels.gitterm_account ? accountHash(labels.gitterm_account) : undefined;
|
|
1636
|
+
if (wanted && info.labels.gitterm_account_hash !== wanted)
|
|
1637
|
+
continue;
|
|
1638
|
+
items.push(info);
|
|
1639
|
+
}
|
|
1640
|
+
cursor = page.pageInfo?.nextCursor;
|
|
1641
|
+
} while (cursor && items.length < 500);
|
|
1642
|
+
} catch (error) {
|
|
1643
|
+
throw failure(error, "list sandboxes");
|
|
1644
|
+
}
|
|
1645
|
+
return items;
|
|
1646
|
+
},
|
|
1647
|
+
destroy,
|
|
1648
|
+
async pause(id) {
|
|
1649
|
+
try {
|
|
1650
|
+
await client.stop({ boxId: id });
|
|
1651
|
+
} catch (error) {
|
|
1652
|
+
throw failure(error, "stop the sandbox");
|
|
1653
|
+
}
|
|
1654
|
+
const deadline = Date.now() + 120000;
|
|
1655
|
+
while ((await get(id)).state !== "archived") {
|
|
1656
|
+
if (Date.now() > deadline)
|
|
1657
|
+
throw new SandboxProviderError("timeout", "Boat did not finish pausing the sandbox.");
|
|
1658
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
1659
|
+
}
|
|
1660
|
+
},
|
|
1661
|
+
async resume(id) {
|
|
1662
|
+
try {
|
|
1663
|
+
await client.resume({ boxId: id, resumeRequest: { noEnv: true } });
|
|
1664
|
+
await waitUntilReady(client, id);
|
|
1665
|
+
} catch (error) {
|
|
1666
|
+
throw failure(error, "resume the sandbox");
|
|
1667
|
+
}
|
|
1668
|
+
},
|
|
1669
|
+
async keepAlive(id, timeoutMs) {
|
|
1670
|
+
try {
|
|
1671
|
+
await client.update({
|
|
1672
|
+
boxId: id,
|
|
1673
|
+
updateBoxRequest: { ttlSeconds: Math.ceil(timeoutMs / 1000) }
|
|
1674
|
+
});
|
|
1675
|
+
} catch (error) {
|
|
1676
|
+
throw failure(error, "extend the sandbox lifetime");
|
|
1677
|
+
}
|
|
1678
|
+
},
|
|
1679
|
+
async run(id, script, options = {}) {
|
|
1680
|
+
await running(id);
|
|
1681
|
+
const exports = Object.entries(options.env ?? {}).map(([key, value]) => {
|
|
1682
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
1683
|
+
throw new Error(`Invalid variable name: ${key}`);
|
|
1684
|
+
return `export ${key}=${shellQuote(value)}; `;
|
|
1685
|
+
}).join("");
|
|
1686
|
+
const cd = options.cwd ? `cd ${shellQuote(options.cwd)} && ` : "";
|
|
1687
|
+
return command(id, `${exports}${cd}sh -c ${shellQuote(script)}`, options.timeoutMs);
|
|
1688
|
+
},
|
|
1689
|
+
async writeFile(id, path, data) {
|
|
1690
|
+
await running(id);
|
|
1691
|
+
const parts = [];
|
|
1692
|
+
try {
|
|
1693
|
+
for (let offset = 0;offset < Math.max(1, data.byteLength); offset += WRITE_CHUNK_BYTES) {
|
|
1694
|
+
const part = data.byteLength <= WRITE_CHUNK_BYTES ? path : `${path}.part${parts.length}`;
|
|
1695
|
+
await client.writeFile({
|
|
1696
|
+
boxId: id,
|
|
1697
|
+
fileWriteRequest: {
|
|
1698
|
+
path: part,
|
|
1699
|
+
content: Buffer.from(data.subarray(offset, offset + WRITE_CHUNK_BYTES)).toString("base64"),
|
|
1700
|
+
encoding: "base64"
|
|
1701
|
+
}
|
|
1702
|
+
});
|
|
1703
|
+
parts.push(part);
|
|
1704
|
+
}
|
|
1705
|
+
} catch (error) {
|
|
1706
|
+
throw failure(error, "upload a file");
|
|
1707
|
+
}
|
|
1708
|
+
if (parts.length > 1) {
|
|
1709
|
+
const quoted = parts.map(shellQuote).join(" ");
|
|
1710
|
+
const joined = await command(id, `cat ${quoted} > ${shellQuote(path)} && rm -f ${quoted}`);
|
|
1711
|
+
if (joined.exitCode !== 0)
|
|
1712
|
+
throw new SandboxProviderError("provider", "Could not assemble the upload in the box.");
|
|
1713
|
+
}
|
|
1714
|
+
},
|
|
1715
|
+
async readFile(id, path) {
|
|
1716
|
+
await running(id);
|
|
1717
|
+
try {
|
|
1718
|
+
const result = await client.readFile({ boxId: id, path, encoding: "base64" });
|
|
1719
|
+
return new Uint8Array(Buffer.from(result.content, "base64"));
|
|
1720
|
+
} catch (error) {
|
|
1721
|
+
throw failure(error, "download a file");
|
|
1722
|
+
}
|
|
1723
|
+
},
|
|
1724
|
+
async preview(id, port) {
|
|
1725
|
+
validatePort(port);
|
|
1726
|
+
await running(id);
|
|
1727
|
+
const hosted = await command(id, `host ${port} >/dev/null && host url ${port}`);
|
|
1728
|
+
const url = hosted.stdout.match(/https:\/\/\S+/)?.[0];
|
|
1729
|
+
if (hosted.exitCode !== 0 || !url)
|
|
1730
|
+
throw new SandboxProviderError("provider", `Boat did not return a URL for port ${port}.`);
|
|
1731
|
+
const parsed = new URL(url);
|
|
1732
|
+
const token = parsed.searchParams.get("_token");
|
|
1733
|
+
parsed.searchParams.delete("_token");
|
|
1734
|
+
const headers = token ? { cookie: `_port_auth=${token}` } : {};
|
|
1735
|
+
return { url: parsed.toString(), headers };
|
|
1736
|
+
},
|
|
1737
|
+
snapshots: {
|
|
1738
|
+
async create(id, name) {
|
|
1739
|
+
try {
|
|
1740
|
+
await client.saveNamedSnapshot({ namedSnapshotSaveRequest: { boxId: id, name } });
|
|
1741
|
+
const deadline = Date.now() + 30 * 60000;
|
|
1742
|
+
while (true) {
|
|
1743
|
+
const { snapshot } = await client.getNamedSnapshot({ name });
|
|
1744
|
+
if (snapshot.status === "ready")
|
|
1745
|
+
return name;
|
|
1746
|
+
if (snapshot.status === "failed")
|
|
1747
|
+
throw new SandboxProviderError("provider", `Boat could not save the snapshot${snapshot.error ? `: ${snapshot.error}` : "."}`);
|
|
1748
|
+
if (Date.now() > deadline)
|
|
1749
|
+
throw new SandboxProviderError("timeout", "Boat did not finish saving the snapshot.");
|
|
1750
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
1751
|
+
}
|
|
1752
|
+
} catch (error) {
|
|
1753
|
+
throw failure(error, "snapshot the sandbox");
|
|
1754
|
+
}
|
|
1755
|
+
},
|
|
1756
|
+
async delete(snapshotId) {
|
|
1757
|
+
try {
|
|
1758
|
+
await client.deleteNamedSnapshot({ name: snapshotId });
|
|
1759
|
+
} catch (error) {
|
|
1760
|
+
const classified = failure(error, "delete the snapshot");
|
|
1761
|
+
if (classified.code !== "not_found")
|
|
1762
|
+
throw classified;
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// ../agent-runtime/src/sandbox/daytona.ts
|
|
1770
|
+
import { Daytona, Image } from "@daytonaio/sdk";
|
|
1771
|
+
var profile2 = SANDBOX_PROVIDER_PROFILES.daytona;
|
|
1772
|
+
var DEFAULT_DAYTONA_SANDBOX_IMAGE = "node:22-bookworm-slim";
|
|
1773
|
+
var failure2 = (error, action) => classifyFailure("Daytona", error, action);
|
|
1774
|
+
function infoFrom2(sandbox) {
|
|
1775
|
+
const state = String(sandbox.state ?? "");
|
|
1776
|
+
return {
|
|
1777
|
+
id: sandbox.id,
|
|
1778
|
+
state: ["started"].includes(state) ? "running" : ["stopped", "paused", "archived", "stopping", "pausing", "archiving"].includes(state) ? "paused" : ["destroyed", "destroying", "error", "build_failed"].includes(state) ? "terminated" : "pending",
|
|
1779
|
+
labels: sandbox.labels ?? {},
|
|
1780
|
+
startedAt: sandbox.createdAt ?? null,
|
|
1781
|
+
expiresAt: sandbox.labels?.gitterm_expires_at ?? null
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
function createDaytonaSandboxTransport(config) {
|
|
1785
|
+
if (!config.apiKey?.trim())
|
|
1786
|
+
throw new SandboxProviderError("auth", "Daytona apiKey is empty.");
|
|
1787
|
+
const client = new Daytona({
|
|
1788
|
+
apiKey: config.apiKey,
|
|
1789
|
+
...config.target ? { target: config.target } : {}
|
|
1790
|
+
});
|
|
1791
|
+
const get = async (id) => {
|
|
1792
|
+
try {
|
|
1793
|
+
return await client.get(id);
|
|
1794
|
+
} catch (error) {
|
|
1795
|
+
throw failure2(error, "read the sandbox");
|
|
1796
|
+
}
|
|
1797
|
+
};
|
|
1798
|
+
const running = async (id) => {
|
|
1799
|
+
const sandbox = await get(id);
|
|
1800
|
+
if (infoFrom2(sandbox).state !== "running")
|
|
1801
|
+
throw new SandboxProviderError("not_found", `The Daytona sandbox is ${sandbox.state}.`);
|
|
1802
|
+
return sandbox;
|
|
1803
|
+
};
|
|
1804
|
+
return {
|
|
1805
|
+
provider: "daytona",
|
|
1806
|
+
...profile2,
|
|
1807
|
+
async create(input) {
|
|
1808
|
+
validateLifetime(input.timeoutMs, profile2.capabilities.maxLifetimeMs);
|
|
1809
|
+
const minutes = Math.ceil(input.timeoutMs / 60000);
|
|
1810
|
+
const kill = (input.onTimeout ?? "kill") === "kill";
|
|
1811
|
+
const resources = input.machine?.resources ?? config.resources;
|
|
1812
|
+
let sandbox;
|
|
1813
|
+
try {
|
|
1814
|
+
sandbox = await client.create({
|
|
1815
|
+
...input.snapshot ?? config.snapshot ? { snapshot: input.snapshot ?? config.snapshot } : {
|
|
1816
|
+
image: Image.base(input.template ?? DEFAULT_DAYTONA_SANDBOX_IMAGE).entrypoint([
|
|
1817
|
+
"sleep",
|
|
1818
|
+
"infinity"
|
|
1819
|
+
]),
|
|
1820
|
+
...resources ? { resources } : {}
|
|
1821
|
+
},
|
|
1822
|
+
labels: {
|
|
1823
|
+
...input.labels,
|
|
1824
|
+
...kill ? { gitterm_expires_at: new Date(Date.now() + minutes * 60000).toISOString() } : {}
|
|
1825
|
+
},
|
|
1826
|
+
public: input.publicPreviews === true,
|
|
1827
|
+
...kill ? { ttlMinutes: minutes, autoStopInterval: minutes, autoDeleteInterval: 0 } : { autoStopInterval: minutes, autoDeleteInterval: -1 }
|
|
1828
|
+
}, { timeout: 600 });
|
|
1829
|
+
} catch (error) {
|
|
1830
|
+
throw failure2(error, "create a sandbox");
|
|
1831
|
+
}
|
|
1832
|
+
try {
|
|
1833
|
+
const check = await sandbox.process.executeCommand("true", undefined, undefined, 30);
|
|
1834
|
+
if (check.exitCode !== 0)
|
|
1835
|
+
throw new Error("sandbox did not run a command");
|
|
1836
|
+
return infoFrom2(sandbox);
|
|
1837
|
+
} catch (error) {
|
|
1838
|
+
return discardFailedCreate(() => sandbox.delete(), sandbox.id, failure2(error, "prepare the sandbox"));
|
|
1839
|
+
}
|
|
1840
|
+
},
|
|
1841
|
+
async info(id) {
|
|
1842
|
+
try {
|
|
1843
|
+
const sandbox = await client.get(id);
|
|
1844
|
+
return infoFrom2(sandbox);
|
|
1845
|
+
} catch (error) {
|
|
1846
|
+
const classified = failure2(error, "read the sandbox");
|
|
1847
|
+
if (classified.code === "not_found")
|
|
1848
|
+
return terminated(id);
|
|
1849
|
+
throw classified;
|
|
1850
|
+
}
|
|
1851
|
+
},
|
|
1852
|
+
async list(labels) {
|
|
1853
|
+
try {
|
|
1854
|
+
const items = [];
|
|
1855
|
+
for await (const sandbox of client.list({ labels })) {
|
|
1856
|
+
items.push(infoFrom2(sandbox));
|
|
1857
|
+
if (items.length >= 500)
|
|
1858
|
+
break;
|
|
1859
|
+
}
|
|
1860
|
+
return items.filter((item) => item.state !== "terminated");
|
|
1861
|
+
} catch (error) {
|
|
1862
|
+
throw failure2(error, "list sandboxes");
|
|
1863
|
+
}
|
|
1864
|
+
},
|
|
1865
|
+
async destroy(id) {
|
|
1866
|
+
try {
|
|
1867
|
+
await (await client.get(id)).delete(180, true);
|
|
1868
|
+
} catch (error) {
|
|
1869
|
+
const classified = failure2(error, "delete the sandbox");
|
|
1870
|
+
if (classified.code !== "not_found")
|
|
1871
|
+
throw classified;
|
|
1872
|
+
}
|
|
1873
|
+
},
|
|
1874
|
+
async pause(id) {
|
|
1875
|
+
try {
|
|
1876
|
+
await (await get(id)).stop();
|
|
1877
|
+
} catch (error) {
|
|
1878
|
+
throw failure2(error, "stop the sandbox");
|
|
1879
|
+
}
|
|
1880
|
+
},
|
|
1881
|
+
async resume(id) {
|
|
1882
|
+
try {
|
|
1883
|
+
await (await get(id)).start();
|
|
1884
|
+
} catch (error) {
|
|
1885
|
+
throw failure2(error, "start the sandbox");
|
|
1886
|
+
}
|
|
1887
|
+
},
|
|
1888
|
+
async keepAlive(id) {
|
|
1889
|
+
try {
|
|
1890
|
+
await (await get(id)).refreshActivity();
|
|
1891
|
+
} catch (error) {
|
|
1892
|
+
throw failure2(error, "record sandbox activity");
|
|
1893
|
+
}
|
|
1894
|
+
},
|
|
1895
|
+
async run(id, command, options = {}) {
|
|
1896
|
+
const sandbox = await running(id);
|
|
1897
|
+
try {
|
|
1898
|
+
const result = await sandbox.process.executeCommand(command, options.cwd, options.env, Math.ceil((options.timeoutMs ?? 60000) / 1000));
|
|
1899
|
+
return { exitCode: result.exitCode, stdout: result.result, stderr: "" };
|
|
1900
|
+
} catch (error) {
|
|
1901
|
+
throw failure2(error, "run a command");
|
|
1902
|
+
}
|
|
1903
|
+
},
|
|
1904
|
+
async writeFile(id, path, data) {
|
|
1905
|
+
const sandbox = await running(id);
|
|
1906
|
+
try {
|
|
1907
|
+
await sandbox.fs.uploadFile(Buffer.from(data), path);
|
|
1908
|
+
} catch (error) {
|
|
1909
|
+
throw failure2(error, "upload a file");
|
|
1910
|
+
}
|
|
1911
|
+
},
|
|
1912
|
+
async readFile(id, path) {
|
|
1913
|
+
const sandbox = await running(id);
|
|
1914
|
+
try {
|
|
1915
|
+
return new Uint8Array(await sandbox.fs.downloadFile(path));
|
|
1916
|
+
} catch (error) {
|
|
1917
|
+
throw failure2(error, "download a file");
|
|
1918
|
+
}
|
|
1919
|
+
},
|
|
1920
|
+
async preview(id, port) {
|
|
1921
|
+
validatePort(port);
|
|
1922
|
+
const sandbox = await running(id);
|
|
1923
|
+
try {
|
|
1924
|
+
const link = await sandbox.getPreviewLink(port);
|
|
1925
|
+
const headers = link.token ? { "x-daytona-preview-token": link.token, "X-Daytona-Skip-Preview-Warning": "true" } : {};
|
|
1926
|
+
return { url: link.url, headers };
|
|
1927
|
+
} catch (error) {
|
|
1928
|
+
throw failure2(error, "get a preview link");
|
|
1929
|
+
}
|
|
1930
|
+
},
|
|
1931
|
+
snapshots: {
|
|
1932
|
+
async create(id, name) {
|
|
1933
|
+
try {
|
|
1934
|
+
await (await running(id)).createSnapshot(name, 1800);
|
|
1935
|
+
return name;
|
|
1936
|
+
} catch (error) {
|
|
1937
|
+
throw failure2(error, "snapshot the sandbox");
|
|
1938
|
+
}
|
|
1939
|
+
},
|
|
1940
|
+
async delete(snapshotId) {
|
|
1941
|
+
try {
|
|
1942
|
+
await client.snapshot.delete(await client.snapshot.get(snapshotId));
|
|
1943
|
+
} catch (error) {
|
|
1944
|
+
const classified = failure2(error, "delete the snapshot");
|
|
1945
|
+
if (classified.code !== "not_found")
|
|
1946
|
+
throw classified;
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
};
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
// ../agent-runtime/src/sandbox/e2b.ts
|
|
1954
|
+
import {
|
|
1955
|
+
AuthenticationError,
|
|
1956
|
+
CommandExitError,
|
|
1957
|
+
NotFoundError,
|
|
1958
|
+
RateLimitError,
|
|
1959
|
+
Sandbox,
|
|
1960
|
+
TimeoutError
|
|
1961
|
+
} from "e2b";
|
|
1962
|
+
var DEFAULT_E2B_TEMPLATE = "base";
|
|
1963
|
+
var profile3 = SANDBOX_PROVIDER_PROFILES.e2b;
|
|
1964
|
+
var MAX_LIFETIME_MS = profile3.capabilities.maxLifetimeMs;
|
|
1965
|
+
var EXPIRES_LABEL = "gitterm_expires_at";
|
|
1966
|
+
var failure3 = (error, action) => {
|
|
1967
|
+
if (error instanceof RateLimitError)
|
|
1968
|
+
return new SandboxProviderError("rate_limited", `E2B rate-limited the request to ${action}.`);
|
|
1969
|
+
if (error instanceof TimeoutError)
|
|
1970
|
+
return new SandboxProviderError("timeout", `E2B timed out while trying to ${action}.`);
|
|
1971
|
+
return classifyFailure("E2B", error, action, {
|
|
1972
|
+
auth: (e) => e instanceof AuthenticationError,
|
|
1973
|
+
notFound: (e) => e instanceof NotFoundError
|
|
1974
|
+
});
|
|
1975
|
+
};
|
|
1976
|
+
function infoFrom3(info) {
|
|
1977
|
+
return {
|
|
1978
|
+
id: info.sandboxId,
|
|
1979
|
+
state: info.state === "paused" ? "paused" : "running",
|
|
1980
|
+
labels: info.metadata ?? {},
|
|
1981
|
+
startedAt: info.startedAt.toISOString(),
|
|
1982
|
+
expiresAt: info.metadata?.[EXPIRES_LABEL] ?? info.endAt.toISOString()
|
|
1983
|
+
};
|
|
1984
|
+
}
|
|
1985
|
+
function createE2BSandboxTransport(config) {
|
|
1986
|
+
const apiKey = config.apiKey?.trim();
|
|
1987
|
+
if (!apiKey)
|
|
1988
|
+
throw new SandboxProviderError("auth", "E2B apiKey is empty.");
|
|
1989
|
+
const opts = { apiKey };
|
|
1990
|
+
async function info(id) {
|
|
1991
|
+
try {
|
|
1992
|
+
return infoFrom3(await Sandbox.getInfo(id, opts));
|
|
1993
|
+
} catch (error) {
|
|
1994
|
+
const classified = failure3(error, "read the sandbox");
|
|
1995
|
+
if (classified.code === "not_found")
|
|
1996
|
+
return terminated(id);
|
|
1997
|
+
throw classified;
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
async function connect(id) {
|
|
2001
|
+
const current = await info(id);
|
|
2002
|
+
if (current.state === "terminated")
|
|
2003
|
+
throw new SandboxProviderError("not_found", "The sandbox no longer exists.");
|
|
2004
|
+
const remaining = Date.parse(current.expiresAt ?? "") - Date.now();
|
|
2005
|
+
if (!(remaining > 1000))
|
|
2006
|
+
throw new SandboxProviderError("not_found", "The sandbox has reached its lifetime limit.");
|
|
2007
|
+
try {
|
|
2008
|
+
return await Sandbox.connect(id, { ...opts, timeoutMs: remaining });
|
|
2009
|
+
} catch (error) {
|
|
2010
|
+
throw failure3(error, "connect to the sandbox");
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
return {
|
|
2014
|
+
provider: "e2b",
|
|
2015
|
+
...profile3,
|
|
2016
|
+
async create(input) {
|
|
2017
|
+
validateLifetime(input.timeoutMs, MAX_LIFETIME_MS);
|
|
2018
|
+
const onTimeout = input.onTimeout ?? "kill";
|
|
2019
|
+
const labels = {
|
|
2020
|
+
...input.labels,
|
|
2021
|
+
...onTimeout === "kill" ? { [EXPIRES_LABEL]: new Date(Date.now() + input.timeoutMs).toISOString() } : {}
|
|
2022
|
+
};
|
|
2023
|
+
let sandbox;
|
|
2024
|
+
try {
|
|
2025
|
+
const template = input.template ?? config.template ?? DEFAULT_E2B_TEMPLATE;
|
|
2026
|
+
const source = input.snapshot ?? config.snapshot ?? `${template}${input.machine?.templateSuffix ?? ""}`;
|
|
2027
|
+
sandbox = await Sandbox.create(source, {
|
|
2028
|
+
...opts,
|
|
2029
|
+
timeoutMs: input.timeoutMs,
|
|
2030
|
+
metadata: labels,
|
|
2031
|
+
...input.env ? { envs: input.env } : {},
|
|
2032
|
+
lifecycle: { onTimeout },
|
|
2033
|
+
network: { allowPublicTraffic: input.publicPreviews === true }
|
|
2034
|
+
});
|
|
2035
|
+
} catch (error) {
|
|
2036
|
+
throw failure3(error, "create a sandbox");
|
|
2037
|
+
}
|
|
2038
|
+
try {
|
|
2039
|
+
await sandbox.commands.run("true", { timeoutMs: 30000 });
|
|
2040
|
+
return infoFrom3(await Sandbox.getInfo(sandbox.sandboxId, opts));
|
|
2041
|
+
} catch (error) {
|
|
2042
|
+
return discardFailedCreate((id) => Sandbox.kill(id, opts), sandbox.sandboxId, failure3(error, "prepare the sandbox"));
|
|
2043
|
+
}
|
|
2044
|
+
},
|
|
2045
|
+
info,
|
|
2046
|
+
async list(labels) {
|
|
2047
|
+
try {
|
|
2048
|
+
const paginator = Sandbox.list({
|
|
2049
|
+
...opts,
|
|
2050
|
+
query: {
|
|
2051
|
+
...Object.keys(labels).length ? { metadata: labels } : {},
|
|
2052
|
+
state: ["running", "paused"]
|
|
2053
|
+
}
|
|
2054
|
+
});
|
|
2055
|
+
const items = [];
|
|
2056
|
+
while (paginator.hasNext && items.length < 500) {
|
|
2057
|
+
for (const item of await paginator.nextItems())
|
|
2058
|
+
items.push(infoFrom3(item));
|
|
2059
|
+
}
|
|
2060
|
+
return items;
|
|
2061
|
+
} catch (error) {
|
|
2062
|
+
throw failure3(error, "list sandboxes");
|
|
2063
|
+
}
|
|
2064
|
+
},
|
|
2065
|
+
async destroy(id) {
|
|
2066
|
+
try {
|
|
2067
|
+
await Sandbox.kill(id, opts);
|
|
2068
|
+
} catch (error) {
|
|
2069
|
+
const classified = failure3(error, "delete the sandbox");
|
|
2070
|
+
if (classified.code !== "not_found")
|
|
2071
|
+
throw classified;
|
|
2072
|
+
}
|
|
2073
|
+
},
|
|
2074
|
+
async pause(id) {
|
|
2075
|
+
try {
|
|
2076
|
+
await Sandbox.pause(id, opts);
|
|
2077
|
+
} catch (error) {
|
|
2078
|
+
throw failure3(error, "pause the sandbox");
|
|
2079
|
+
}
|
|
2080
|
+
},
|
|
2081
|
+
async resume(id) {
|
|
2082
|
+
try {
|
|
2083
|
+
await Sandbox.connect(id, opts);
|
|
2084
|
+
} catch (error) {
|
|
2085
|
+
throw failure3(error, "resume the sandbox");
|
|
2086
|
+
}
|
|
2087
|
+
},
|
|
2088
|
+
async keepAlive(id, timeoutMs) {
|
|
2089
|
+
try {
|
|
2090
|
+
await Sandbox.setTimeout(id, timeoutMs, opts);
|
|
2091
|
+
} catch (error) {
|
|
2092
|
+
throw failure3(error, "extend the sandbox timeout");
|
|
2093
|
+
}
|
|
2094
|
+
},
|
|
2095
|
+
async run(id, command, options = {}) {
|
|
2096
|
+
const sandbox = await connect(id);
|
|
2097
|
+
try {
|
|
2098
|
+
const result = await sandbox.commands.run(command, {
|
|
2099
|
+
cwd: options.cwd,
|
|
2100
|
+
envs: options.env,
|
|
2101
|
+
timeoutMs: options.timeoutMs ?? 60000
|
|
2102
|
+
});
|
|
2103
|
+
return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr };
|
|
2104
|
+
} catch (error) {
|
|
2105
|
+
if (error instanceof CommandExitError)
|
|
2106
|
+
return { exitCode: error.exitCode, stdout: error.stdout, stderr: error.stderr };
|
|
2107
|
+
throw failure3(error, "run a command");
|
|
2108
|
+
}
|
|
2109
|
+
},
|
|
2110
|
+
async writeFile(id, path, data) {
|
|
2111
|
+
const sandbox = await connect(id);
|
|
2112
|
+
try {
|
|
2113
|
+
await sandbox.files.write(path, new Blob([data]));
|
|
2114
|
+
} catch (error) {
|
|
2115
|
+
throw failure3(error, "upload a file");
|
|
2116
|
+
}
|
|
2117
|
+
},
|
|
2118
|
+
async readFile(id, path) {
|
|
2119
|
+
const sandbox = await connect(id);
|
|
2120
|
+
try {
|
|
2121
|
+
return await sandbox.files.read(path, { format: "bytes" });
|
|
2122
|
+
} catch (error) {
|
|
2123
|
+
throw failure3(error, "download a file");
|
|
2124
|
+
}
|
|
2125
|
+
},
|
|
2126
|
+
async preview(id, port) {
|
|
2127
|
+
validatePort(port);
|
|
2128
|
+
const sandbox = await connect(id);
|
|
2129
|
+
const token = sandbox.trafficAccessToken;
|
|
2130
|
+
const headers = token ? { "e2b-traffic-access-token": token } : {};
|
|
2131
|
+
return { url: `https://${sandbox.getHost(port)}`, headers };
|
|
2132
|
+
},
|
|
2133
|
+
snapshots: {
|
|
2134
|
+
async create(id, name) {
|
|
2135
|
+
try {
|
|
2136
|
+
return (await Sandbox.createSnapshot(id, { ...opts, name })).snapshotId;
|
|
2137
|
+
} catch (error) {
|
|
2138
|
+
throw failure3(error, "snapshot the sandbox");
|
|
2139
|
+
}
|
|
2140
|
+
},
|
|
2141
|
+
async delete(snapshotId) {
|
|
2142
|
+
try {
|
|
2143
|
+
await Sandbox.deleteSnapshot(snapshotId, opts);
|
|
2144
|
+
} catch (error) {
|
|
2145
|
+
const classified = failure3(error, "delete the snapshot");
|
|
2146
|
+
if (classified.code !== "not_found")
|
|
2147
|
+
throw classified;
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
// ../agent-runtime/src/sandbox/exedev.ts
|
|
2155
|
+
import { randomBytes as randomBytes6 } from "node:crypto";
|
|
2156
|
+
var profile4 = SANDBOX_PROVIDER_PROFILES.exedev;
|
|
2157
|
+
var DEFAULT_EXEDEV_SANDBOX_IMAGE = "node:22-bookworm";
|
|
2158
|
+
var NAME_PREFIX2 = "gitterm-sbx-";
|
|
2159
|
+
var EXIT_MARKER = "__GITTERM_EXIT__";
|
|
2160
|
+
var WRITE_CHUNK_BYTES2 = 40 * 1024;
|
|
2161
|
+
var READ_CHUNK_BYTES = 4 * 1024 * 1024;
|
|
2162
|
+
function createExeDevSandboxTransport(config) {
|
|
2163
|
+
if (!config.apiKey?.trim())
|
|
2164
|
+
throw new SandboxProviderError("auth", "exe.dev token is empty.");
|
|
2165
|
+
const accessKeys = new Map;
|
|
2166
|
+
async function exec(command, action) {
|
|
2167
|
+
let response;
|
|
2168
|
+
try {
|
|
2169
|
+
response = await fetch("https://exe.dev/exec", {
|
|
2170
|
+
method: "POST",
|
|
2171
|
+
headers: { authorization: `Bearer ${config.apiKey}`, "content-type": "text/plain" },
|
|
2172
|
+
body: command,
|
|
2173
|
+
signal: AbortSignal.timeout(45000)
|
|
2174
|
+
});
|
|
2175
|
+
} catch (error) {
|
|
2176
|
+
throw classifyFailure("exe.dev", error, action);
|
|
2177
|
+
}
|
|
2178
|
+
const text = await response.text();
|
|
2179
|
+
if (response.status === 401 || response.status === 403)
|
|
2180
|
+
throw new SandboxProviderError("auth", `exe.dev rejected the token while trying to ${action}. Tokens need cmds new, ls, ssh, ssh-key, pause, resume, and rm.`);
|
|
2181
|
+
if (response.status === 504)
|
|
2182
|
+
throw new SandboxProviderError("timeout", `exe.dev timed out while trying to ${action}.`);
|
|
2183
|
+
if (!response.ok)
|
|
2184
|
+
throw classifyFailure("exe.dev", new Error(`${response.status} ${text.slice(0, 200)}`), action);
|
|
2185
|
+
return text;
|
|
2186
|
+
}
|
|
2187
|
+
const parseList = (text) => {
|
|
2188
|
+
try {
|
|
2189
|
+
return JSON.parse(text);
|
|
2190
|
+
} catch {
|
|
2191
|
+
return {};
|
|
2192
|
+
}
|
|
2193
|
+
};
|
|
2194
|
+
const infoFrom = (name, status) => {
|
|
2195
|
+
const match = new RegExp(`^${NAME_PREFIX2}([a-f0-9]{10})-`).exec(name);
|
|
2196
|
+
return {
|
|
2197
|
+
id: name,
|
|
2198
|
+
state: status === "running" ? "running" : status === "paused" ? "paused" : status === "creating" ? "pending" : "terminated",
|
|
2199
|
+
labels: match ? { gitterm_account_hash: match[1], gitterm_workload: "sandbox" } : {},
|
|
2200
|
+
startedAt: null,
|
|
2201
|
+
expiresAt: null
|
|
2202
|
+
};
|
|
2203
|
+
};
|
|
2204
|
+
async function info(id) {
|
|
2205
|
+
if (!/^[a-z0-9-]+$/.test(id))
|
|
2206
|
+
throw new Error("Invalid exe.dev VM name");
|
|
2207
|
+
try {
|
|
2208
|
+
const vm = parseList(await exec(`ls ${id} --json`, "read the sandbox")).vms?.find((item) => item.vm_name === id);
|
|
2209
|
+
return vm ? infoFrom(id, vm.status) : terminated(id);
|
|
2210
|
+
} catch (error) {
|
|
2211
|
+
if (error instanceof SandboxProviderError && error.code === "not_found")
|
|
2212
|
+
return terminated(id);
|
|
2213
|
+
throw error;
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
async function ssh(id, command, timeoutMs = 25000) {
|
|
2217
|
+
const text = await exec(`ssh ${id} -- bash -c ${shellQuote(`timeout ${Math.max(1, Math.floor(timeoutMs / 1000))} bash -c ${shellQuote(command)}; printf '\\n${EXIT_MARKER}%s\\n' "$?"`)}`, "run a command");
|
|
2218
|
+
const match = new RegExp(`\\n?${EXIT_MARKER}(\\d+)\\n?$`).exec(text);
|
|
2219
|
+
if (!match)
|
|
2220
|
+
throw new SandboxProviderError("provider", "exe.dev did not report the command's exit code.");
|
|
2221
|
+
return { exitCode: Number(match[1]), stdout: text.slice(0, match.index), stderr: "" };
|
|
2222
|
+
}
|
|
2223
|
+
async function running(id) {
|
|
2224
|
+
const current = await info(id);
|
|
2225
|
+
if (current.state !== "running")
|
|
2226
|
+
throw new SandboxProviderError("not_found", `The exe.dev VM is ${current.state}.`);
|
|
2227
|
+
}
|
|
2228
|
+
return {
|
|
2229
|
+
provider: "exedev",
|
|
2230
|
+
...profile4,
|
|
2231
|
+
async create(input) {
|
|
2232
|
+
validateLifetime(input.timeoutMs, profile4.capabilities.maxLifetimeMs);
|
|
2233
|
+
const account = input.labels.gitterm_account ?? input.labels.gitterm_workspace ?? "managed";
|
|
2234
|
+
const name = `${NAME_PREFIX2}${accountHash(account)}-${randomBytes6(4).toString("hex")}`;
|
|
2235
|
+
const machine = input.machine ?? config;
|
|
2236
|
+
const size = ["cpu", "memory", "disk"].filter((key) => machine[key]).map((key) => ` --${key}=${shellQuote(String(machine[key]))}`).join("");
|
|
2237
|
+
await exec(`new --name=${name} --no-email --image=${shellQuote(input.template ?? config.image ?? DEFAULT_EXEDEV_SANDBOX_IMAGE)}${size} --json`, "create a sandbox");
|
|
2238
|
+
try {
|
|
2239
|
+
const deadline = Date.now() + 120000;
|
|
2240
|
+
while ((await info(name)).state !== "running") {
|
|
2241
|
+
if (Date.now() > deadline)
|
|
2242
|
+
throw new SandboxProviderError("timeout", "Timed out waiting for the exe.dev VM.");
|
|
2243
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
2244
|
+
}
|
|
2245
|
+
const check = await ssh(name, "true");
|
|
2246
|
+
if (check.exitCode !== 0)
|
|
2247
|
+
throw new Error("VM did not run a command");
|
|
2248
|
+
return info(name);
|
|
2249
|
+
} catch (error) {
|
|
2250
|
+
return discardFailedCreate(() => exec(`rm ${name}`, "delete the sandbox"), name, error);
|
|
2251
|
+
}
|
|
2252
|
+
},
|
|
2253
|
+
info,
|
|
2254
|
+
async list(labels) {
|
|
2255
|
+
const wanted = labels.gitterm_account ? accountHash(labels.gitterm_account) : undefined;
|
|
2256
|
+
return (parseList(await exec("ls --json", "list sandboxes")).vms ?? []).filter((vm) => vm.vm_name.startsWith(wanted ? `${NAME_PREFIX2}${wanted}-` : NAME_PREFIX2)).map((vm) => infoFrom(vm.vm_name, vm.status)).filter((item) => item.state !== "terminated");
|
|
2257
|
+
},
|
|
2258
|
+
async destroy(id) {
|
|
2259
|
+
if ((await info(id)).state === "terminated")
|
|
2260
|
+
return;
|
|
2261
|
+
await exec(`rm ${id}`, "delete the sandbox");
|
|
2262
|
+
},
|
|
2263
|
+
async pause(id) {
|
|
2264
|
+
await exec(`pause ${id}`, "pause the sandbox");
|
|
2265
|
+
},
|
|
2266
|
+
async resume(id) {
|
|
2267
|
+
await exec(`resume ${id}`, "resume the sandbox");
|
|
2268
|
+
},
|
|
2269
|
+
async run(id, command, options = {}) {
|
|
2270
|
+
await running(id);
|
|
2271
|
+
const exports = Object.entries(options.env ?? {}).map(([key, value]) => {
|
|
2272
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
2273
|
+
throw new Error(`Invalid variable name: ${key}`);
|
|
2274
|
+
return `export ${key}=${shellQuote(value)}; `;
|
|
2275
|
+
}).join("");
|
|
2276
|
+
const cd = options.cwd ? `cd ${shellQuote(options.cwd)} && ` : "";
|
|
2277
|
+
return ssh(id, `${exports}${cd}${command}`, Math.min(options.timeoutMs ?? 25000, 25000));
|
|
2278
|
+
},
|
|
2279
|
+
async writeFile(id, path, data) {
|
|
2280
|
+
await running(id);
|
|
2281
|
+
const target = shellQuote(path);
|
|
2282
|
+
const begin = await ssh(id, `mkdir -p "$(dirname ${target})" && : > ${target}`);
|
|
2283
|
+
if (begin.exitCode !== 0)
|
|
2284
|
+
throw new SandboxProviderError("provider", "Could not create the file in the VM.");
|
|
2285
|
+
for (let offset = 0;offset < data.byteLength; offset += WRITE_CHUNK_BYTES2) {
|
|
2286
|
+
const chunk = Buffer.from(data.subarray(offset, offset + WRITE_CHUNK_BYTES2)).toString("base64");
|
|
2287
|
+
const appended = await ssh(id, `printf %s ${chunk} | base64 -d >> ${target}`);
|
|
2288
|
+
if (appended.exitCode !== 0)
|
|
2289
|
+
throw new SandboxProviderError("provider", "Could not upload the file to the VM.");
|
|
2290
|
+
}
|
|
2291
|
+
},
|
|
2292
|
+
async readFile(id, path) {
|
|
2293
|
+
await running(id);
|
|
2294
|
+
const size = await ssh(id, `wc -c < ${shellQuote(path)}`);
|
|
2295
|
+
if (size.exitCode !== 0)
|
|
2296
|
+
throw new SandboxProviderError("not_found", `No file at ${path} in the VM.`);
|
|
2297
|
+
const total = Number(size.stdout.trim());
|
|
2298
|
+
const chunks = [];
|
|
2299
|
+
for (let block = 0;block * READ_CHUNK_BYTES < total; block++) {
|
|
2300
|
+
const part = await ssh(id, `dd if=${shellQuote(path)} bs=${READ_CHUNK_BYTES} skip=${block} count=1 2>/dev/null | base64 -w0`);
|
|
2301
|
+
if (part.exitCode !== 0)
|
|
2302
|
+
throw new SandboxProviderError("provider", "Could not download the file from the VM.");
|
|
2303
|
+
chunks.push(Buffer.from(part.stdout.trim(), "base64"));
|
|
2304
|
+
}
|
|
2305
|
+
return new Uint8Array(Buffer.concat(chunks));
|
|
2306
|
+
},
|
|
2307
|
+
async preview(id, port) {
|
|
2308
|
+
validatePort(port);
|
|
2309
|
+
if (port < 3000 || port > 9999)
|
|
2310
|
+
throw new Error("exe.dev previews support ports 3000-9999");
|
|
2311
|
+
await running(id);
|
|
2312
|
+
let key = accessKeys.get(id);
|
|
2313
|
+
if (!key) {
|
|
2314
|
+
const output = await exec(`ssh-key generate-api-key --vm=${id} --label=gitterm-sandbox --exp=never`, "create a preview key");
|
|
2315
|
+
key = output.match(/exe[01]\.[A-Za-z0-9._-]+/)?.[0];
|
|
2316
|
+
if (!key)
|
|
2317
|
+
throw new SandboxProviderError("provider", "exe.dev did not return a VM access key.");
|
|
2318
|
+
accessKeys.set(id, key);
|
|
2319
|
+
}
|
|
2320
|
+
return {
|
|
2321
|
+
url: `https://${id}.exe.xyz:${port}/`,
|
|
2322
|
+
headers: { "X-Exedev-Authorization": `Bearer ${key}` }
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
2325
|
+
};
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
// ../agent-runtime/src/sandbox/railway.ts
|
|
2329
|
+
import { randomBytes as randomBytes7 } from "node:crypto";
|
|
2330
|
+
|
|
2331
|
+
// ../agent-runtime/src/sandbox/exec-server.ts
|
|
2332
|
+
import { createHmac } from "node:crypto";
|
|
2333
|
+
var EXEC_SERVER_SOURCE = String.raw`
|
|
2334
|
+
const http = require("http"), cp = require("child_process"), fs = require("fs"), path = require("path"), crypto = require("crypto");
|
|
2335
|
+
const token = Buffer.from(process.env.GITTERM_EXEC_TOKEN || "");
|
|
2336
|
+
delete process.env.GITTERM_EXEC_TOKEN;
|
|
2337
|
+
delete process.env.GITTERM_EXEC_SERVER;
|
|
2338
|
+
const MAX = 32 * 1024 * 1024;
|
|
2339
|
+
const authorized = (req) => {
|
|
2340
|
+
const given = Buffer.from(String(req.headers["x-gitterm-token"] || ""));
|
|
2341
|
+
return token.length > 0 && given.length === token.length && crypto.timingSafeEqual(given, token);
|
|
2342
|
+
};
|
|
2343
|
+
const body = (req) => new Promise((resolve, reject) => {
|
|
2344
|
+
const chunks = []; let size = 0;
|
|
2345
|
+
req.on("data", (c) => { size += c.length; if (size > MAX) { reject(new Error("too large")); req.destroy(); } else chunks.push(c); });
|
|
2346
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
2347
|
+
req.on("error", reject);
|
|
2348
|
+
});
|
|
2349
|
+
const server = http.createServer(async (req, res) => {
|
|
2350
|
+
const url = new URL(req.url, "http://sandbox");
|
|
2351
|
+
if (url.pathname === "/healthz" && !req.headers["x-gitterm-port"]) return res.end("ok");
|
|
2352
|
+
if (!authorized(req)) return res.writeHead(401).end();
|
|
2353
|
+
const port = Number(req.headers["x-gitterm-port"]);
|
|
2354
|
+
if (port) {
|
|
2355
|
+
const headers = { ...req.headers };
|
|
2356
|
+
delete headers["x-gitterm-token"]; delete headers["x-gitterm-port"];
|
|
2357
|
+
const upstream = http.request({ host: "127.0.0.1", port, method: req.method, path: req.url, headers }, (reply) => {
|
|
2358
|
+
res.writeHead(reply.statusCode || 502, reply.headers);
|
|
2359
|
+
reply.pipe(res);
|
|
2360
|
+
});
|
|
2361
|
+
upstream.on("error", () => { if (!res.headersSent) res.writeHead(502); res.end(); });
|
|
2362
|
+
return req.pipe(upstream);
|
|
2363
|
+
}
|
|
2364
|
+
try {
|
|
2365
|
+
if (url.pathname === "/run" && req.method === "POST") {
|
|
2366
|
+
const input = JSON.parse((await body(req)).toString());
|
|
2367
|
+
const child = cp.spawn("sh", ["-c", input.command], {
|
|
2368
|
+
cwd: input.cwd || process.env.HOME || "/",
|
|
2369
|
+
env: { ...process.env, ...(input.env || {}) },
|
|
2370
|
+
});
|
|
2371
|
+
const out = [], err = []; let kept = 0;
|
|
2372
|
+
const keep = (into) => (chunk) => { if (kept < 16 * 1024 * 1024) { into.push(chunk); kept += chunk.length; } };
|
|
2373
|
+
child.stdout.on("data", keep(out)); child.stderr.on("data", keep(err));
|
|
2374
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), input.timeoutMs || 60000);
|
|
2375
|
+
child.on("error", () => {});
|
|
2376
|
+
child.on("close", (code) => {
|
|
2377
|
+
clearTimeout(timer);
|
|
2378
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
2379
|
+
res.end(JSON.stringify({ exitCode: code === null ? 137 : code, stdout: Buffer.concat(out).toString(), stderr: Buffer.concat(err).toString() }));
|
|
2380
|
+
});
|
|
2381
|
+
return;
|
|
2382
|
+
}
|
|
2383
|
+
const file = url.searchParams.get("path");
|
|
2384
|
+
if (url.pathname === "/file" && file && path.isAbsolute(file)) {
|
|
2385
|
+
if (req.method === "PUT") {
|
|
2386
|
+
const data = await body(req);
|
|
2387
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
2388
|
+
fs.writeFileSync(file, data);
|
|
2389
|
+
return res.end("ok");
|
|
2390
|
+
}
|
|
2391
|
+
if (req.method === "GET") return res.end(fs.readFileSync(file));
|
|
2392
|
+
}
|
|
2393
|
+
res.writeHead(404).end();
|
|
2394
|
+
} catch (error) {
|
|
2395
|
+
res.writeHead(error && error.code === "ENOENT" ? 404 : 500).end();
|
|
2396
|
+
}
|
|
2397
|
+
});
|
|
2398
|
+
server.on("upgrade", (req, socket) => socket.destroy());
|
|
2399
|
+
server.listen(Number(process.env.PORT || 8080), "::");
|
|
2400
|
+
`;
|
|
2401
|
+
var EXEC_SERVER_PORT = 8080;
|
|
2402
|
+
var EXEC_SERVER_START_COMMAND = `sh -c 'node -e "$GITTERM_EXEC_SERVER"'`;
|
|
2403
|
+
function execServerToken(credential, sandboxName) {
|
|
2404
|
+
return createHmac("sha256", credential).update(`gitterm-exec:${sandboxName}`).digest("hex");
|
|
2405
|
+
}
|
|
2406
|
+
function createExecServerClient(baseUrl, token, provider, routing = {}) {
|
|
2407
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
2408
|
+
const request = async (path, init) => {
|
|
2409
|
+
let response;
|
|
2410
|
+
try {
|
|
2411
|
+
response = await fetch(`${base}${path}`, {
|
|
2412
|
+
...init,
|
|
2413
|
+
headers: { ...init.headers, ...routing, "x-gitterm-token": token },
|
|
2414
|
+
redirect: "error",
|
|
2415
|
+
signal: AbortSignal.timeout(init.timeoutMs ?? 120000)
|
|
2416
|
+
});
|
|
2417
|
+
} catch {
|
|
2418
|
+
throw new SandboxProviderError("provider", `Could not reach the ${provider} sandbox command server.`);
|
|
2419
|
+
}
|
|
2420
|
+
if (response.status === 401)
|
|
2421
|
+
throw new SandboxProviderError("auth", `The ${provider} sandbox rejected the derived access token.`);
|
|
2422
|
+
if (response.status === 404)
|
|
2423
|
+
throw new SandboxProviderError("not_found", "File or endpoint not found in the sandbox.");
|
|
2424
|
+
if (!response.ok)
|
|
2425
|
+
throw new SandboxProviderError("provider", `The ${provider} sandbox command server failed (${response.status}).`);
|
|
2426
|
+
return response;
|
|
2427
|
+
};
|
|
2428
|
+
return {
|
|
2429
|
+
async ready() {
|
|
2430
|
+
return fetch(`${base}/healthz`, { headers: routing, signal: AbortSignal.timeout(5000) }).then((response) => response.ok).catch(() => false);
|
|
2431
|
+
},
|
|
2432
|
+
async run(command, options = {}) {
|
|
2433
|
+
const timeoutMs = options.timeoutMs ?? 60000;
|
|
2434
|
+
const response = await request("/run", {
|
|
2435
|
+
method: "POST",
|
|
2436
|
+
headers: { "content-type": "application/json" },
|
|
2437
|
+
body: JSON.stringify({ command, cwd: options.cwd, env: options.env, timeoutMs }),
|
|
2438
|
+
timeoutMs: timeoutMs + 15000
|
|
2439
|
+
});
|
|
2440
|
+
return await response.json();
|
|
2441
|
+
},
|
|
2442
|
+
async writeFile(path, data) {
|
|
2443
|
+
await request(`/file?path=${encodeURIComponent(path)}`, {
|
|
2444
|
+
method: "PUT",
|
|
2445
|
+
body: data
|
|
2446
|
+
});
|
|
2447
|
+
},
|
|
2448
|
+
async readFile(path) {
|
|
2449
|
+
return new Uint8Array(await (await request(`/file?path=${encodeURIComponent(path)}`, { method: "GET" })).arrayBuffer());
|
|
2450
|
+
},
|
|
2451
|
+
preview(port) {
|
|
2452
|
+
return {
|
|
2453
|
+
url: `${base}/`,
|
|
2454
|
+
headers: { ...routing, "x-gitterm-token": token, "x-gitterm-port": String(port) }
|
|
2455
|
+
};
|
|
2456
|
+
}
|
|
2457
|
+
};
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
// ../agent-runtime/src/sandbox/railway.ts
|
|
2461
|
+
var profile5 = SANDBOX_PROVIDER_PROFILES.railway;
|
|
2462
|
+
var DEFAULT_RAILWAY_SANDBOX_IMAGE = "node:22-bookworm-slim";
|
|
2463
|
+
var NAME_PREFIX3 = "gitterm-sbx-";
|
|
2464
|
+
var DEPLOY_TIMEOUT_MS = 5 * 60000;
|
|
2465
|
+
function createRailwaySandboxTransport(config) {
|
|
2466
|
+
if (!config.apiKey?.trim())
|
|
2467
|
+
throw new SandboxProviderError("auth", "Railway token is empty.");
|
|
2468
|
+
if (!config.projectId?.trim() || !config.environmentId?.trim())
|
|
2469
|
+
throw new Error("Railway sandboxes need projectId and environmentId");
|
|
2470
|
+
const apiUrl = config.apiUrl?.trim() || "https://backboard.railway.app/graphql/v2";
|
|
2471
|
+
const { environmentId, projectId } = config;
|
|
2472
|
+
async function gql(query, variables, action) {
|
|
2473
|
+
let response;
|
|
2474
|
+
try {
|
|
2475
|
+
response = await fetch(apiUrl, {
|
|
2476
|
+
method: "POST",
|
|
2477
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${config.apiKey}` },
|
|
2478
|
+
body: JSON.stringify({ query, variables }),
|
|
2479
|
+
signal: AbortSignal.timeout(60000)
|
|
2480
|
+
});
|
|
2481
|
+
} catch (error) {
|
|
2482
|
+
throw classifyFailure("Railway", error, action);
|
|
2483
|
+
}
|
|
2484
|
+
if (response.status === 401 || response.status === 403)
|
|
2485
|
+
throw new SandboxProviderError("auth", `Railway rejected the token while trying to ${action}.`);
|
|
2486
|
+
const result = await response.json().catch(() => ({}));
|
|
2487
|
+
if (!response.ok || result.errors?.length) {
|
|
2488
|
+
const message = result.errors?.map((error) => error.message).join("; ") ?? `HTTP ${response.status}`;
|
|
2489
|
+
throw classifyFailure("Railway", new Error(message), action);
|
|
2490
|
+
}
|
|
2491
|
+
return result.data;
|
|
2492
|
+
}
|
|
2493
|
+
async function snapshot(id) {
|
|
2494
|
+
try {
|
|
2495
|
+
return await gql(`query($serviceId: String!, $environmentId: String!, $projectId: String!) {
|
|
2496
|
+
service(id: $serviceId) { id name createdAt deletedAt }
|
|
2497
|
+
serviceInstance(environmentId: $environmentId, serviceId: $serviceId) {
|
|
2498
|
+
latestDeployment { id status }
|
|
2499
|
+
domains { serviceDomains { domain targetPort } }
|
|
2500
|
+
}
|
|
2501
|
+
variables(projectId: $projectId, environmentId: $environmentId, serviceId: $serviceId)
|
|
2502
|
+
}`, { serviceId: id, environmentId, projectId }, "read the sandbox");
|
|
2503
|
+
} catch (error) {
|
|
2504
|
+
if (error instanceof SandboxProviderError && error.code === "not_found")
|
|
2505
|
+
return null;
|
|
2506
|
+
throw error;
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
const infoFrom = (id, data) => {
|
|
2510
|
+
if (!data?.service || data.service.deletedAt)
|
|
2511
|
+
return terminated(id);
|
|
2512
|
+
const status = data.serviceInstance?.latestDeployment?.status;
|
|
2513
|
+
let labels = {};
|
|
2514
|
+
try {
|
|
2515
|
+
labels = JSON.parse(data.variables?.GITTERM_LABELS ?? "{}");
|
|
2516
|
+
} catch {}
|
|
2517
|
+
return {
|
|
2518
|
+
id,
|
|
2519
|
+
state: status === "SUCCESS" ? "running" : !status || ["REMOVED", "SLEEPING", "REMOVING"].includes(status) ? "paused" : ["FAILED", "CRASHED", "SKIPPED"].includes(status) ? "terminated" : "pending",
|
|
2520
|
+
labels,
|
|
2521
|
+
startedAt: data.service.createdAt ?? null,
|
|
2522
|
+
expiresAt: null
|
|
2523
|
+
};
|
|
2524
|
+
};
|
|
2525
|
+
async function connect(id) {
|
|
2526
|
+
const data = await snapshot(id);
|
|
2527
|
+
const info = infoFrom(id, data);
|
|
2528
|
+
if (info.state !== "running")
|
|
2529
|
+
throw new SandboxProviderError("not_found", `The Railway sandbox is ${info.state}.`);
|
|
2530
|
+
const domain = data?.serviceInstance?.domains?.serviceDomains?.[0]?.domain;
|
|
2531
|
+
if (!domain)
|
|
2532
|
+
throw new SandboxProviderError("provider", "The Railway sandbox has no domain.");
|
|
2533
|
+
return createExecServerClient(`https://${domain}`, execServerToken(config.apiKey, data.service.name), "Railway");
|
|
2534
|
+
}
|
|
2535
|
+
async function deploy(id) {
|
|
2536
|
+
await gql(`mutation($environmentId: String!, $serviceId: String!) { serviceInstanceDeploy(environmentId: $environmentId, serviceId: $serviceId, latestCommit: true) }`, { environmentId, serviceId: id }, "deploy the sandbox");
|
|
2537
|
+
const deadline = Date.now() + DEPLOY_TIMEOUT_MS;
|
|
2538
|
+
while (Date.now() < deadline) {
|
|
2539
|
+
const data = await snapshot(id);
|
|
2540
|
+
const status = data?.serviceInstance?.latestDeployment?.status;
|
|
2541
|
+
if (status && ["FAILED", "CRASHED"].includes(status))
|
|
2542
|
+
throw new SandboxProviderError("provider", `The Railway deployment ended with ${status}.`);
|
|
2543
|
+
if (status === "SUCCESS") {
|
|
2544
|
+
const client = await connect(id);
|
|
2545
|
+
if (await client.ready())
|
|
2546
|
+
return;
|
|
2547
|
+
}
|
|
2548
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
2549
|
+
}
|
|
2550
|
+
throw new SandboxProviderError("timeout", "Timed out waiting for the Railway sandbox to start.");
|
|
2551
|
+
}
|
|
2552
|
+
async function destroy(id) {
|
|
2553
|
+
try {
|
|
2554
|
+
await gql(`mutation($id: String!) { serviceDelete(id: $id) }`, { id }, "delete the sandbox");
|
|
2555
|
+
} catch (error) {
|
|
2556
|
+
if (!(error instanceof SandboxProviderError && error.code === "not_found"))
|
|
2557
|
+
throw error;
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
return {
|
|
2561
|
+
provider: "railway",
|
|
2562
|
+
...profile5,
|
|
2563
|
+
async create(input) {
|
|
2564
|
+
validateLifetime(input.timeoutMs, profile5.capabilities.maxLifetimeMs);
|
|
2565
|
+
const name = `${NAME_PREFIX3}${randomBytes7(6).toString("hex")}`;
|
|
2566
|
+
const created = await gql(`mutation($input: ServiceCreateInput!) { serviceCreate(input: $input) { id } }`, {
|
|
2567
|
+
input: {
|
|
2568
|
+
projectId,
|
|
2569
|
+
environmentId,
|
|
2570
|
+
name,
|
|
2571
|
+
variables: {
|
|
2572
|
+
GITTERM_LABELS: JSON.stringify(input.labels),
|
|
2573
|
+
GITTERM_EXEC_TOKEN: execServerToken(config.apiKey, name),
|
|
2574
|
+
GITTERM_EXEC_SERVER: EXEC_SERVER_SOURCE,
|
|
2575
|
+
PORT: String(EXEC_SERVER_PORT)
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
}, "create a sandbox");
|
|
2579
|
+
const id = created.serviceCreate.id;
|
|
2580
|
+
try {
|
|
2581
|
+
await gql(`mutation($environmentId: String!, $serviceId: String!, $input: ServiceInstanceUpdateInput!) {
|
|
2582
|
+
serviceInstanceUpdate(environmentId: $environmentId, serviceId: $serviceId, input: $input)
|
|
2583
|
+
}`, {
|
|
2584
|
+
environmentId,
|
|
2585
|
+
serviceId: id,
|
|
2586
|
+
input: {
|
|
2587
|
+
source: { image: input.template ?? config.image ?? DEFAULT_RAILWAY_SANDBOX_IMAGE },
|
|
2588
|
+
startCommand: EXEC_SERVER_START_COMMAND,
|
|
2589
|
+
healthcheckPath: "/healthz"
|
|
2590
|
+
}
|
|
2591
|
+
}, "configure the sandbox");
|
|
2592
|
+
await gql(`mutation($input: ServiceDomainCreateInput!) { serviceDomainCreate(input: $input) { id } }`, { input: { environmentId, serviceId: id, targetPort: EXEC_SERVER_PORT } }, "create the sandbox domain");
|
|
2593
|
+
await deploy(id);
|
|
2594
|
+
return infoFrom(id, await snapshot(id));
|
|
2595
|
+
} catch (error) {
|
|
2596
|
+
return discardFailedCreate(destroy, id, error);
|
|
2597
|
+
}
|
|
2598
|
+
},
|
|
2599
|
+
async info(id) {
|
|
2600
|
+
return infoFrom(id, await snapshot(id));
|
|
2601
|
+
},
|
|
2602
|
+
async list(labels) {
|
|
2603
|
+
const data = await gql(`query($projectId: String!) { project(id: $projectId) { services { edges { node { id name } } } } }`, { projectId }, "list sandboxes");
|
|
2604
|
+
const items = [];
|
|
2605
|
+
for (const { node } of data.project.services.edges) {
|
|
2606
|
+
if (!node.name.startsWith(NAME_PREFIX3))
|
|
2607
|
+
continue;
|
|
2608
|
+
const info = infoFrom(node.id, await snapshot(node.id));
|
|
2609
|
+
if (info.state !== "terminated" && Object.entries(labels).every(([key, value]) => info.labels[key] === value))
|
|
2610
|
+
items.push(info);
|
|
2611
|
+
}
|
|
2612
|
+
return items;
|
|
2613
|
+
},
|
|
2614
|
+
destroy,
|
|
2615
|
+
async pause(id) {
|
|
2616
|
+
const deployment = (await snapshot(id))?.serviceInstance?.latestDeployment;
|
|
2617
|
+
if (!deployment || deployment.status === "REMOVED")
|
|
2618
|
+
return;
|
|
2619
|
+
await gql(`mutation($id: String!) { deploymentRemove(id: $id) }`, { id: deployment.id }, "pause the sandbox");
|
|
2620
|
+
},
|
|
2621
|
+
resume: deploy,
|
|
2622
|
+
async run(id, command, options) {
|
|
2623
|
+
return (await connect(id)).run(command, options);
|
|
2624
|
+
},
|
|
2625
|
+
async writeFile(id, path, data) {
|
|
2626
|
+
return (await connect(id)).writeFile(path, data);
|
|
2627
|
+
},
|
|
2628
|
+
async readFile(id, path) {
|
|
2629
|
+
return (await connect(id)).readFile(path);
|
|
2630
|
+
},
|
|
2631
|
+
async preview(id, port) {
|
|
2632
|
+
validatePort(port);
|
|
2633
|
+
if (port === EXEC_SERVER_PORT)
|
|
2634
|
+
throw new Error(`Port ${EXEC_SERVER_PORT} is reserved for Gitterm in Railway sandboxes`);
|
|
2635
|
+
return (await connect(id)).preview(port);
|
|
2636
|
+
}
|
|
2637
|
+
};
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
// ../agent-runtime/src/sandbox/vercel.ts
|
|
2641
|
+
import { randomBytes as randomBytes8 } from "node:crypto";
|
|
2642
|
+
import { Sandbox as Sandbox2, Snapshot } from "@vercel/sandbox";
|
|
2643
|
+
var profile6 = SANDBOX_PROVIDER_PROFILES.vercel;
|
|
2644
|
+
var failure4 = (error, action) => classifyFailure("Vercel", error, action);
|
|
2645
|
+
var TAG_PRIORITY = [
|
|
2646
|
+
"gitterm_account",
|
|
2647
|
+
"gitterm_workload",
|
|
2648
|
+
"gitterm_workspace",
|
|
2649
|
+
"gitterm_user",
|
|
2650
|
+
"gitterm_name",
|
|
2651
|
+
"gitterm_purpose",
|
|
2652
|
+
"gitterm_session"
|
|
2653
|
+
];
|
|
2654
|
+
function limitTags(labels) {
|
|
2655
|
+
const rank = (key) => TAG_PRIORITY.indexOf(key) + 1 || 99;
|
|
2656
|
+
const keys = Object.keys(labels).toSorted((a, b) => rank(a) - rank(b));
|
|
2657
|
+
return Object.fromEntries(keys.slice(0, 5).map((key) => [key, labels[key]]));
|
|
2658
|
+
}
|
|
2659
|
+
function summaryInfo(summary) {
|
|
2660
|
+
return {
|
|
2661
|
+
id: summary.name,
|
|
2662
|
+
state: state(summary.status, summary.persistent),
|
|
2663
|
+
labels: summary.tags ?? {},
|
|
2664
|
+
startedAt: new Date(summary.createdAt).toISOString(),
|
|
2665
|
+
expiresAt: !summary.persistent && summary.timeout ? new Date(summary.createdAt + summary.timeout).toISOString() : null
|
|
2666
|
+
};
|
|
2667
|
+
}
|
|
2668
|
+
function state(status, persistent) {
|
|
2669
|
+
if (status === "running")
|
|
2670
|
+
return "running";
|
|
2671
|
+
if (status === "pending" || status === "snapshotting")
|
|
2672
|
+
return "pending";
|
|
2673
|
+
if ((status === "stopping" || status === "stopped") && persistent)
|
|
2674
|
+
return "paused";
|
|
2675
|
+
return "terminated";
|
|
2676
|
+
}
|
|
2677
|
+
function createVercelSandboxTransport(config) {
|
|
2678
|
+
if (!config.apiKey?.trim())
|
|
2679
|
+
throw new SandboxProviderError("auth", "Vercel token is empty.");
|
|
2680
|
+
if (!config.teamId?.trim() || !config.projectId?.trim())
|
|
2681
|
+
throw new Error("Vercel sandboxes need teamId and projectId");
|
|
2682
|
+
const credentials = { token: config.apiKey, teamId: config.teamId, projectId: config.projectId };
|
|
2683
|
+
const infoFrom = (sandbox) => summaryInfo({
|
|
2684
|
+
name: sandbox.name,
|
|
2685
|
+
status: sandbox.status,
|
|
2686
|
+
persistent: sandbox.persistent,
|
|
2687
|
+
tags: sandbox.tags,
|
|
2688
|
+
createdAt: sandbox.createdAt.getTime(),
|
|
2689
|
+
timeout: sandbox.timeout
|
|
2690
|
+
});
|
|
2691
|
+
const get = async (id) => {
|
|
2692
|
+
try {
|
|
2693
|
+
return await Sandbox2.get({ ...credentials, name: id });
|
|
2694
|
+
} catch (error) {
|
|
2695
|
+
throw failure4(error, "read the sandbox");
|
|
2696
|
+
}
|
|
2697
|
+
};
|
|
2698
|
+
const running = async (id) => {
|
|
2699
|
+
const sandbox = await get(id);
|
|
2700
|
+
if (sandbox.status !== "running")
|
|
2701
|
+
throw new SandboxProviderError("not_found", `The Vercel sandbox is ${sandbox.status}.`);
|
|
2702
|
+
return sandbox;
|
|
2703
|
+
};
|
|
2704
|
+
return {
|
|
2705
|
+
provider: "vercel",
|
|
2706
|
+
...profile6,
|
|
2707
|
+
async create(input) {
|
|
2708
|
+
validateLifetime(input.timeoutMs, profile6.capabilities.maxLifetimeMs);
|
|
2709
|
+
const kill = (input.onTimeout ?? "kill") === "kill";
|
|
2710
|
+
const name = `gitterm-sbx-${randomBytes8(6).toString("hex")}`;
|
|
2711
|
+
const snapshot = input.snapshot ?? config.snapshot;
|
|
2712
|
+
const vcpus = input.machine?.vcpus ? Number(input.machine.vcpus) : config.vcpus;
|
|
2713
|
+
let sandbox;
|
|
2714
|
+
try {
|
|
2715
|
+
sandbox = await Sandbox2.create({
|
|
2716
|
+
...credentials,
|
|
2717
|
+
name,
|
|
2718
|
+
...snapshot ? { source: { type: "snapshot", snapshotId: snapshot } } : { runtime: config.runtime ?? "node24" },
|
|
2719
|
+
timeout: input.timeoutMs,
|
|
2720
|
+
persistent: !kill,
|
|
2721
|
+
tags: limitTags(input.labels),
|
|
2722
|
+
...vcpus ? { resources: { vcpus } } : {}
|
|
2723
|
+
});
|
|
2724
|
+
} catch (error) {
|
|
2725
|
+
throw failure4(error, "create a sandbox");
|
|
2726
|
+
}
|
|
2727
|
+
try {
|
|
2728
|
+
const check = await sandbox.runCommand("true");
|
|
2729
|
+
if (check.exitCode !== 0)
|
|
2730
|
+
throw new Error("sandbox did not run a command");
|
|
2731
|
+
return infoFrom(sandbox);
|
|
2732
|
+
} catch (error) {
|
|
2733
|
+
return discardFailedCreate(() => sandbox.delete(), sandbox.name, failure4(error, "prepare the sandbox"));
|
|
2734
|
+
}
|
|
2735
|
+
},
|
|
2736
|
+
async info(id) {
|
|
2737
|
+
try {
|
|
2738
|
+
return infoFrom(await Sandbox2.get({ ...credentials, name: id }));
|
|
2739
|
+
} catch (error) {
|
|
2740
|
+
const classified = failure4(error, "read the sandbox");
|
|
2741
|
+
if (classified.code === "not_found")
|
|
2742
|
+
return terminated(id);
|
|
2743
|
+
throw classified;
|
|
2744
|
+
}
|
|
2745
|
+
},
|
|
2746
|
+
async list(labels) {
|
|
2747
|
+
const [first] = Object.entries(labels);
|
|
2748
|
+
try {
|
|
2749
|
+
const pages = await Sandbox2.list({
|
|
2750
|
+
...credentials,
|
|
2751
|
+
...first ? { tags: { [first[0]]: first[1] } } : {}
|
|
2752
|
+
});
|
|
2753
|
+
const items = [];
|
|
2754
|
+
for await (const summary of pages) {
|
|
2755
|
+
const info = summaryInfo(summary);
|
|
2756
|
+
if (Object.entries(labels).every(([key, value]) => info.labels[key] === value) && info.state !== "terminated")
|
|
2757
|
+
items.push(info);
|
|
2758
|
+
if (items.length >= 500)
|
|
2759
|
+
break;
|
|
2760
|
+
}
|
|
2761
|
+
return items;
|
|
2762
|
+
} catch (error) {
|
|
2763
|
+
throw failure4(error, "list sandboxes");
|
|
2764
|
+
}
|
|
2765
|
+
},
|
|
2766
|
+
async destroy(id) {
|
|
2767
|
+
try {
|
|
2768
|
+
await (await Sandbox2.get({ ...credentials, name: id })).delete();
|
|
2769
|
+
} catch (error) {
|
|
2770
|
+
const classified = failure4(error, "delete the sandbox");
|
|
2771
|
+
if (classified.code !== "not_found")
|
|
2772
|
+
throw classified;
|
|
2773
|
+
}
|
|
2774
|
+
},
|
|
2775
|
+
async pause(id) {
|
|
2776
|
+
try {
|
|
2777
|
+
await (await get(id)).stop();
|
|
2778
|
+
} catch (error) {
|
|
2779
|
+
throw failure4(error, "stop the sandbox");
|
|
2780
|
+
}
|
|
2781
|
+
},
|
|
2782
|
+
async resume(id) {
|
|
2783
|
+
try {
|
|
2784
|
+
await Sandbox2.get({ ...credentials, name: id, resume: true });
|
|
2785
|
+
} catch (error) {
|
|
2786
|
+
throw failure4(error, "resume the sandbox");
|
|
2787
|
+
}
|
|
2788
|
+
},
|
|
2789
|
+
async keepAlive(id, timeoutMs) {
|
|
2790
|
+
try {
|
|
2791
|
+
await (await get(id)).update({ timeout: timeoutMs });
|
|
2792
|
+
} catch (error) {
|
|
2793
|
+
throw failure4(error, "extend the sandbox timeout");
|
|
2794
|
+
}
|
|
2795
|
+
},
|
|
2796
|
+
async run(id, command, options = {}) {
|
|
2797
|
+
const sandbox = await running(id);
|
|
2798
|
+
try {
|
|
2799
|
+
const result = await sandbox.runCommand({
|
|
2800
|
+
cmd: "bash",
|
|
2801
|
+
args: ["-c", command],
|
|
2802
|
+
cwd: options.cwd,
|
|
2803
|
+
env: options.env,
|
|
2804
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 60000)
|
|
2805
|
+
});
|
|
2806
|
+
return {
|
|
2807
|
+
exitCode: result.exitCode,
|
|
2808
|
+
stdout: await result.stdout(),
|
|
2809
|
+
stderr: await result.stderr()
|
|
2810
|
+
};
|
|
2811
|
+
} catch (error) {
|
|
2812
|
+
throw failure4(error, "run a command");
|
|
2813
|
+
}
|
|
2814
|
+
},
|
|
2815
|
+
async writeFile(id, path, data) {
|
|
2816
|
+
const sandbox = await running(id);
|
|
2817
|
+
try {
|
|
2818
|
+
await sandbox.writeFiles([{ path, content: Buffer.from(data) }]);
|
|
2819
|
+
} catch (error) {
|
|
2820
|
+
throw failure4(error, "upload a file");
|
|
2821
|
+
}
|
|
2822
|
+
},
|
|
2823
|
+
async readFile(id, path) {
|
|
2824
|
+
const sandbox = await running(id);
|
|
2825
|
+
let data;
|
|
2826
|
+
try {
|
|
2827
|
+
data = await sandbox.readFileToBuffer({ path });
|
|
2828
|
+
} catch (error) {
|
|
2829
|
+
throw failure4(error, "download a file");
|
|
2830
|
+
}
|
|
2831
|
+
if (!data)
|
|
2832
|
+
throw new SandboxProviderError("not_found", `No file at ${path} in the sandbox.`);
|
|
2833
|
+
return new Uint8Array(data);
|
|
2834
|
+
},
|
|
2835
|
+
async preview(id, port) {
|
|
2836
|
+
validatePort(port);
|
|
2837
|
+
const sandbox = await running(id);
|
|
2838
|
+
try {
|
|
2839
|
+
const exposed = sandbox.routes.map((route) => route.port);
|
|
2840
|
+
if (!exposed.includes(port))
|
|
2841
|
+
await sandbox.update({ ports: [...exposed, port] });
|
|
2842
|
+
return { url: sandbox.domain(port), headers: {} };
|
|
2843
|
+
} catch (error) {
|
|
2844
|
+
throw failure4(error, "expose the port");
|
|
2845
|
+
}
|
|
2846
|
+
},
|
|
2847
|
+
snapshots: {
|
|
2848
|
+
async create(id) {
|
|
2849
|
+
try {
|
|
2850
|
+
return (await (await running(id)).snapshot({ expiration: 0 })).snapshotId;
|
|
2851
|
+
} catch (error) {
|
|
2852
|
+
throw failure4(error, "snapshot the sandbox");
|
|
2853
|
+
}
|
|
2854
|
+
},
|
|
2855
|
+
async delete(snapshotId) {
|
|
2856
|
+
try {
|
|
2857
|
+
await (await Snapshot.get({ ...credentials, snapshotId })).delete();
|
|
2858
|
+
} catch (error) {
|
|
2859
|
+
const classified = failure4(error, "delete the snapshot");
|
|
2860
|
+
if (classified.code !== "not_found")
|
|
2861
|
+
throw classified;
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
};
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
// ../agent-runtime/src/sandbox/index.ts
|
|
2869
|
+
function createSandboxTransport(config) {
|
|
2870
|
+
switch (config.provider) {
|
|
2871
|
+
case "e2b":
|
|
2872
|
+
return createE2BSandboxTransport(config);
|
|
2873
|
+
case "daytona":
|
|
2874
|
+
return createDaytonaSandboxTransport(config);
|
|
2875
|
+
case "vercel":
|
|
2876
|
+
return createVercelSandboxTransport(config);
|
|
2877
|
+
case "boat":
|
|
2878
|
+
return createBoatSandboxTransport(config);
|
|
2879
|
+
case "exedev":
|
|
2880
|
+
return createExeDevSandboxTransport(config);
|
|
2881
|
+
case "railway":
|
|
2882
|
+
return createRailwaySandboxTransport(config);
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
// src/client.ts
|
|
2887
|
+
import {
|
|
2888
|
+
createTRPCClient,
|
|
2889
|
+
httpBatchLink,
|
|
2890
|
+
httpSubscriptionLink,
|
|
2891
|
+
splitLink,
|
|
2892
|
+
TRPCClientError
|
|
2893
|
+
} from "@trpc/client";
|
|
2894
|
+
import { EventSource } from "eventsource";
|
|
2895
|
+
|
|
2896
|
+
// src/config.ts
|
|
2897
|
+
import { mkdir as mkdir4, readFile as readFile5, unlink, writeFile as writeFile4 } from "node:fs/promises";
|
|
2898
|
+
import { readFileSync } from "node:fs";
|
|
2899
|
+
import { homedir as homedir3 } from "node:os";
|
|
2900
|
+
import { dirname, join } from "node:path";
|
|
2901
|
+
var DEFAULT_GITTERM_SERVER_URL = "https://api.gitterm.dev";
|
|
2902
|
+
function getConfigPath(configPath) {
|
|
2903
|
+
return configPath ?? join(homedir3(), ".config", "gitterm", "cli.json");
|
|
2904
|
+
}
|
|
2905
|
+
async function ensureConfigDir(path) {
|
|
2906
|
+
await mkdir4(dirname(path), { recursive: true });
|
|
2907
|
+
}
|
|
2908
|
+
function parseConfig(text) {
|
|
2909
|
+
const parsed = JSON.parse(text);
|
|
2910
|
+
if (!parsed.token || !parsed.serverUrl)
|
|
2911
|
+
return null;
|
|
2912
|
+
return {
|
|
2913
|
+
serverUrl: parsed.serverUrl,
|
|
2914
|
+
token: parsed.token,
|
|
2915
|
+
createdAt: parsed.createdAt ?? Date.now()
|
|
2916
|
+
};
|
|
2917
|
+
}
|
|
2918
|
+
async function loadConfig(configPath) {
|
|
2919
|
+
try {
|
|
2920
|
+
const text = await readFile5(getConfigPath(configPath), "utf-8");
|
|
2921
|
+
return parseConfig(text);
|
|
2922
|
+
} catch {
|
|
2923
|
+
return null;
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
function loadConfigSync(configPath) {
|
|
2927
|
+
try {
|
|
2928
|
+
const text = readFileSync(getConfigPath(configPath), "utf-8");
|
|
2929
|
+
return parseConfig(text);
|
|
2930
|
+
} catch {
|
|
2931
|
+
return null;
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
async function saveConfig(config, configPath) {
|
|
2935
|
+
const path = getConfigPath(configPath);
|
|
2936
|
+
await ensureConfigDir(path);
|
|
2937
|
+
await writeFile4(path, JSON.stringify(config, null, 2), "utf-8");
|
|
2938
|
+
}
|
|
2939
|
+
async function deleteConfig(configPath) {
|
|
2940
|
+
try {
|
|
2941
|
+
await unlink(getConfigPath(configPath));
|
|
2942
|
+
} catch {}
|
|
2943
|
+
}
|
|
2944
|
+
|
|
2945
|
+
// src/transport.ts
|
|
2946
|
+
var LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
2947
|
+
function normalizeServerUrl(value) {
|
|
2948
|
+
let url;
|
|
2949
|
+
try {
|
|
2950
|
+
url = new URL(value);
|
|
2951
|
+
} catch {
|
|
2952
|
+
throw new Error(`Invalid GitTerm server URL: ${value}`);
|
|
2953
|
+
}
|
|
2954
|
+
if (url.username || url.password) {
|
|
2955
|
+
throw new Error("GitTerm server URL must not contain credentials");
|
|
2956
|
+
}
|
|
2957
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname))) {
|
|
2958
|
+
throw new Error("GitTerm server URL must use HTTPS (HTTP is allowed only for loopback)");
|
|
2959
|
+
}
|
|
2960
|
+
url.hash = "";
|
|
2961
|
+
url.search = "";
|
|
2962
|
+
return url.toString().replace(/\/$/, "");
|
|
2963
|
+
}
|
|
2964
|
+
function withPath(serverUrl, pathname) {
|
|
2965
|
+
const url = new URL(serverUrl);
|
|
2966
|
+
url.pathname = pathname;
|
|
2967
|
+
return url.toString();
|
|
2968
|
+
}
|
|
2969
|
+
function basePath(serverUrl) {
|
|
2970
|
+
return new URL(serverUrl).pathname.replace(/\/+$/, "");
|
|
2971
|
+
}
|
|
2972
|
+
function trpcEndpoint(serverUrl) {
|
|
2973
|
+
const base = basePath(serverUrl);
|
|
2974
|
+
return withPath(serverUrl, base.endsWith("/trpc") ? base : `${base}/trpc`);
|
|
2975
|
+
}
|
|
2976
|
+
function apiEndpoint(serverUrl, route) {
|
|
2977
|
+
const base = basePath(serverUrl);
|
|
2978
|
+
const apiBase = base.endsWith("/api") ? base : `${base}/api`;
|
|
2979
|
+
return withPath(serverUrl, `${apiBase}/${route.replace(/^\/+/, "")}`);
|
|
2980
|
+
}
|
|
2981
|
+
function createNoRedirectFetch(fetchImpl = fetch) {
|
|
2982
|
+
return async (input, init) => {
|
|
2983
|
+
const response = await fetchImpl(input, { ...init, redirect: "manual" });
|
|
2984
|
+
if (response.status >= 300 && response.status < 400) {
|
|
2985
|
+
const location = response.headers.get("location");
|
|
2986
|
+
throw new Error(location ? `GitTerm server redirects are not allowed: ${location}` : "GitTerm server redirects are not allowed");
|
|
2987
|
+
}
|
|
2988
|
+
return response;
|
|
2989
|
+
};
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
// src/runs.ts
|
|
2993
|
+
function terminal(status) {
|
|
2994
|
+
return status === "completed" || status === "failed" || status === "cancelled";
|
|
2995
|
+
}
|
|
2996
|
+
function aborted(signal) {
|
|
2997
|
+
if (signal?.aborted)
|
|
2998
|
+
throw new GittermError("ABORTED", "Run observation was aborted; the agent was not cancelled");
|
|
2999
|
+
}
|
|
3000
|
+
async function abortable(operation, signal) {
|
|
3001
|
+
if (signal.aborted)
|
|
3002
|
+
operation.catch(() => {});
|
|
3003
|
+
aborted(signal);
|
|
3004
|
+
let onAbort;
|
|
3005
|
+
try {
|
|
3006
|
+
return await Promise.race([
|
|
3007
|
+
operation,
|
|
3008
|
+
new Promise((_, reject) => {
|
|
3009
|
+
onAbort = () => reject(new GittermError("ABORTED", "Run observation was aborted; the agent was not cancelled"));
|
|
3010
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
3011
|
+
})
|
|
3012
|
+
]);
|
|
3013
|
+
} finally {
|
|
3014
|
+
if (onAbort)
|
|
3015
|
+
signal.removeEventListener("abort", onAbort);
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
function delay(ms, signal) {
|
|
3019
|
+
aborted(signal);
|
|
3020
|
+
return new Promise((resolve, reject) => {
|
|
3021
|
+
const onAbort = () => {
|
|
3022
|
+
clearTimeout(timer);
|
|
3023
|
+
reject(new GittermError("ABORTED", "Operation aborted"));
|
|
3024
|
+
};
|
|
3025
|
+
const timer = setTimeout(() => {
|
|
3026
|
+
signal.removeEventListener("abort", onAbort);
|
|
3027
|
+
resolve();
|
|
3028
|
+
}, ms);
|
|
3029
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
3030
|
+
});
|
|
3031
|
+
}
|
|
3032
|
+
async function deadline(options, operation) {
|
|
3033
|
+
aborted(options?.signal);
|
|
3034
|
+
const timeoutMs = options?.timeoutMs ?? 30 * 60000;
|
|
3035
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0)
|
|
3036
|
+
throw new GittermError("BAD_REQUEST", "timeoutMs must be finite and non-negative");
|
|
3037
|
+
const controller = new AbortController;
|
|
3038
|
+
const onAbort = () => controller.abort();
|
|
3039
|
+
options?.signal?.addEventListener("abort", onAbort, { once: true });
|
|
3040
|
+
let timedOut = false;
|
|
3041
|
+
const timer = setTimeout(() => {
|
|
3042
|
+
timedOut = true;
|
|
3043
|
+
controller.abort();
|
|
3044
|
+
}, timeoutMs);
|
|
3045
|
+
try {
|
|
3046
|
+
return await operation(controller.signal);
|
|
3047
|
+
} catch (error) {
|
|
3048
|
+
if (timedOut)
|
|
3049
|
+
throw new GittermError("TIMEOUT", "Run observation timed out; the agent was not cancelled", {
|
|
3050
|
+
cause: error
|
|
3051
|
+
});
|
|
3052
|
+
aborted(options?.signal);
|
|
3053
|
+
throw error;
|
|
3054
|
+
} finally {
|
|
3055
|
+
clearTimeout(timer);
|
|
3056
|
+
options?.signal?.removeEventListener("abort", onAbort);
|
|
3057
|
+
controller.abort();
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
async function* runEvents(states) {
|
|
3061
|
+
const seen = new Set;
|
|
3062
|
+
let pending = new Set;
|
|
3063
|
+
let status;
|
|
3064
|
+
for await (const run of states) {
|
|
3065
|
+
if (run.status !== status) {
|
|
3066
|
+
status = run.status;
|
|
3067
|
+
yield { type: "run.status", run };
|
|
3068
|
+
}
|
|
3069
|
+
const current = new Set(run.pendingInputs.map((request) => request.id));
|
|
3070
|
+
for (const requestId of pending) {
|
|
3071
|
+
if (!current.has(requestId))
|
|
3072
|
+
yield { type: "input.resolved", run, requestId };
|
|
3073
|
+
}
|
|
3074
|
+
pending = current;
|
|
3075
|
+
for (const request of run.pendingInputs) {
|
|
3076
|
+
if (seen.has(request.id))
|
|
3077
|
+
continue;
|
|
3078
|
+
seen.add(request.id);
|
|
3079
|
+
yield { type: "input.required", run, request };
|
|
3080
|
+
}
|
|
3081
|
+
if (run.status === "completed" || run.status === "failed" || run.status === "cancelled") {
|
|
3082
|
+
yield { type: `run.${run.status}`, run };
|
|
3083
|
+
return;
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
function runHelpers(operations) {
|
|
3088
|
+
return {
|
|
3089
|
+
events: (ref, options) => runEvents(operations.watch(ref, options)),
|
|
3090
|
+
wait: (ref, options) => deadline(options, async (signal) => {
|
|
3091
|
+
for await (const run of operations.watch(ref, { signal })) {
|
|
3092
|
+
if (terminal(run.status) || run.status === "awaiting_input" && options?.until !== "terminal")
|
|
3093
|
+
return run;
|
|
3094
|
+
}
|
|
3095
|
+
throw new GittermError("NETWORK", "Run stream ended before a terminal state");
|
|
3096
|
+
}),
|
|
3097
|
+
result: (ref, options) => deadline(options, async (signal) => {
|
|
3098
|
+
for await (const event of runEvents(operations.watch(ref, { signal }))) {
|
|
3099
|
+
aborted(signal);
|
|
3100
|
+
if (event.type === "run.completed")
|
|
3101
|
+
return event.run;
|
|
3102
|
+
if (event.type === "run.failed")
|
|
3103
|
+
throw new AgentRunError(event.run, "RUN_FAILED");
|
|
3104
|
+
if (event.type === "run.cancelled")
|
|
3105
|
+
throw new AgentRunError(event.run, "RUN_CANCELLED");
|
|
3106
|
+
if (event.type !== "input.required")
|
|
3107
|
+
continue;
|
|
3108
|
+
const { request } = event;
|
|
3109
|
+
let reply;
|
|
3110
|
+
if (request.kind === "permission") {
|
|
3111
|
+
if (!options?.onPermission)
|
|
3112
|
+
throw new AgentRunError(event.run, "INPUT_REQUIRED");
|
|
3113
|
+
reply = {
|
|
3114
|
+
type: "permission",
|
|
3115
|
+
response: await abortable(Promise.resolve(options.onPermission(request, { signal })), signal)
|
|
3116
|
+
};
|
|
3117
|
+
} else {
|
|
3118
|
+
if (!options?.onQuestion)
|
|
3119
|
+
throw new AgentRunError(event.run, "INPUT_REQUIRED");
|
|
3120
|
+
reply = {
|
|
3121
|
+
type: "question",
|
|
3122
|
+
...await abortable(Promise.resolve(options.onQuestion(request, { signal })), signal)
|
|
3123
|
+
};
|
|
3124
|
+
}
|
|
3125
|
+
aborted(signal);
|
|
3126
|
+
try {
|
|
3127
|
+
await abortable(operations.respond(ref, { requestId: request.id, reply }), signal);
|
|
3128
|
+
} catch (error) {
|
|
3129
|
+
if (!(error instanceof GittermError) || error.code !== "INPUT_NOT_PENDING")
|
|
3130
|
+
throw error;
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
throw new GittermError("NETWORK", "Run stream ended before a terminal state");
|
|
3134
|
+
})
|
|
3135
|
+
};
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
// src/client.ts
|
|
3139
|
+
function toConnection(connection) {
|
|
3140
|
+
const details = connection.details;
|
|
3141
|
+
return {
|
|
3142
|
+
...connection,
|
|
3143
|
+
connectedAt: toIso(connection.connectedAt),
|
|
3144
|
+
details: "lastCheckedAt" in details ? { ...details, lastCheckedAt: toIso(details.lastCheckedAt) } : details
|
|
3145
|
+
};
|
|
3146
|
+
}
|
|
3147
|
+
function envValue(name) {
|
|
3148
|
+
const value = typeof process !== "undefined" ? process.env[name] : undefined;
|
|
3149
|
+
return value && value.trim() ? value : undefined;
|
|
3150
|
+
}
|
|
3151
|
+
function resolveCredentials(options) {
|
|
3152
|
+
const config = !options.serverUrl || !options.token ? loadConfigSync(options.configPath) : null;
|
|
3153
|
+
const serverUrl = options.serverUrl ?? envValue("GITTERM_SERVER_URL") ?? config?.serverUrl ?? DEFAULT_GITTERM_SERVER_URL;
|
|
3154
|
+
const token = options.token ?? envValue("GITTERM_API_TOKEN") ?? config?.token;
|
|
3155
|
+
if (!serverUrl || !token) {
|
|
3156
|
+
throw new GittermError("NOT_LOGGED_IN", "Not logged in. Run: gitterm login");
|
|
3157
|
+
}
|
|
3158
|
+
return { serverUrl: normalizeServerUrl(serverUrl), token };
|
|
3159
|
+
}
|
|
3160
|
+
function toIso(value) {
|
|
3161
|
+
if (!value)
|
|
3162
|
+
return null;
|
|
3163
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
3164
|
+
}
|
|
3165
|
+
function normalizeBaseCommit(value) {
|
|
3166
|
+
if (value == null)
|
|
3167
|
+
return null;
|
|
3168
|
+
const trimmed = value.trim();
|
|
3169
|
+
if (!trimmed)
|
|
3170
|
+
return null;
|
|
3171
|
+
return /^[0-9a-f]{40}([0-9a-f]{24})?$/i.test(trimmed) ? trimmed.toLowerCase() : trimmed;
|
|
3172
|
+
}
|
|
3173
|
+
function workspaceIdOf(ref) {
|
|
3174
|
+
return typeof ref === "string" ? ref : ref.id;
|
|
3175
|
+
}
|
|
3176
|
+
function runTargetOf(ref) {
|
|
3177
|
+
return { workspaceId: ref.workspaceId, runId: ref.id };
|
|
3178
|
+
}
|
|
3179
|
+
function throwIfAborted(signal, what) {
|
|
3180
|
+
if (signal?.aborted)
|
|
3181
|
+
throw new GittermError("ABORTED", `Aborted while ${what}`);
|
|
3182
|
+
}
|
|
3183
|
+
function sleep3(ms, signal, what) {
|
|
3184
|
+
return new Promise((resolve, reject) => {
|
|
3185
|
+
const timer = setTimeout(() => {
|
|
3186
|
+
signal?.removeEventListener("abort", onAbort);
|
|
3187
|
+
resolve();
|
|
3188
|
+
}, ms);
|
|
3189
|
+
const onAbort = () => {
|
|
3190
|
+
clearTimeout(timer);
|
|
3191
|
+
reject(new GittermError("ABORTED", `Aborted while ${what}`));
|
|
3192
|
+
};
|
|
3193
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
3194
|
+
});
|
|
3195
|
+
}
|
|
3196
|
+
function normalizeWorkspace(workspace) {
|
|
3197
|
+
if (!workspace)
|
|
3198
|
+
return null;
|
|
3199
|
+
return {
|
|
3200
|
+
id: workspace.id,
|
|
3201
|
+
name: workspace.name,
|
|
3202
|
+
status: workspace.status,
|
|
3203
|
+
repositoryUrl: workspace.repositoryUrl,
|
|
3204
|
+
repositoryBranch: workspace.repositoryBranch,
|
|
3205
|
+
baseCommit: normalizeBaseCommit(workspace.baseCommit ?? workspace.repositoryBaseCommit ?? null),
|
|
3206
|
+
checkoutRef: workspace.checkoutRef ?? workspace.repositoryCheckoutRef ?? null,
|
|
3207
|
+
domain: workspace.domain,
|
|
3208
|
+
subdomain: workspace.subdomain,
|
|
3209
|
+
persistent: workspace.persistent,
|
|
3210
|
+
hostingType: workspace.hostingType,
|
|
3211
|
+
serverOnly: workspace.serverOnly,
|
|
3212
|
+
workspaceProfile: workspace.workspaceProfile,
|
|
3213
|
+
cloudProviderId: workspace.cloudProviderId,
|
|
3214
|
+
agentType: workspace.image?.agentType ? {
|
|
3215
|
+
id: workspace.image.agentType.id,
|
|
3216
|
+
name: workspace.image.agentType.name,
|
|
3217
|
+
description: workspace.image.agentType.description
|
|
3218
|
+
} : null,
|
|
3219
|
+
image: workspace.image ? {
|
|
3220
|
+
id: workspace.image.id,
|
|
3221
|
+
name: workspace.image.name,
|
|
3222
|
+
imageId: workspace.image.imageId
|
|
3223
|
+
} : null,
|
|
3224
|
+
metadata: workspace.metadata ?? {},
|
|
3225
|
+
autoTerminateAt: toIso(workspace.autoTerminateAt),
|
|
3226
|
+
customImage: workspace.customImage ?? null,
|
|
3227
|
+
startedAt: toIso(workspace.startedAt),
|
|
3228
|
+
pausedAt: toIso(workspace.pausedAt),
|
|
3229
|
+
terminatedAt: toIso(workspace.terminatedAt),
|
|
3230
|
+
lastActiveAt: toIso(workspace.lastActiveAt),
|
|
3231
|
+
updatedAt: toIso(workspace.updatedAt)
|
|
3232
|
+
};
|
|
3233
|
+
}
|
|
3234
|
+
function normalizeRuntime(runtime) {
|
|
3235
|
+
return {
|
|
3236
|
+
workspaceId: runtime.workspaceId,
|
|
3237
|
+
status: runtime.status,
|
|
3238
|
+
url: runtime.url,
|
|
3239
|
+
headers: runtime.headers,
|
|
3240
|
+
password: runtime.password,
|
|
3241
|
+
directory: runtime.directory,
|
|
3242
|
+
repo: runtime.repo,
|
|
3243
|
+
branch: runtime.branch,
|
|
3244
|
+
baseCommit: normalizeBaseCommit(runtime.baseCommit),
|
|
3245
|
+
checkoutRef: runtime.checkoutRef,
|
|
3246
|
+
persistent: runtime.persistent,
|
|
3247
|
+
recoverable: runtime.recoverable,
|
|
3248
|
+
providerKey: runtime.providerKey
|
|
3249
|
+
};
|
|
3250
|
+
}
|
|
3251
|
+
function mapTrpcCode(code) {
|
|
3252
|
+
switch (code) {
|
|
3253
|
+
case "UNAUTHORIZED":
|
|
3254
|
+
return "UNAUTHORIZED";
|
|
3255
|
+
case "NOT_FOUND":
|
|
3256
|
+
return "NOT_FOUND";
|
|
3257
|
+
case "FORBIDDEN":
|
|
3258
|
+
return "FORBIDDEN";
|
|
3259
|
+
case "BAD_REQUEST":
|
|
3260
|
+
return "BAD_REQUEST";
|
|
3261
|
+
case "CONFLICT":
|
|
3262
|
+
return "CONFLICT";
|
|
3263
|
+
case "TIMEOUT":
|
|
3264
|
+
return "TIMEOUT";
|
|
3265
|
+
default:
|
|
3266
|
+
return "SERVER_ERROR";
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3269
|
+
function credentialErrorCode(message) {
|
|
3270
|
+
const match = /^(MODEL_CREDENTIAL_(?:UNAVAILABLE|DUPLICATE_PROVIDER|INVALID|REQUIRED)):\s*/.exec(message);
|
|
3271
|
+
return match?.[1];
|
|
3272
|
+
}
|
|
3273
|
+
function toGittermError(serverUrl, error) {
|
|
3274
|
+
if (error instanceof GittermError)
|
|
3275
|
+
return error;
|
|
3276
|
+
if (error instanceof TRPCClientError) {
|
|
3277
|
+
const data = error.data;
|
|
3278
|
+
if (!data) {
|
|
3279
|
+
return new GittermError("NETWORK", `Could not reach the GitTerm server at ${serverUrl}`, {
|
|
3280
|
+
cause: error.cause ?? error
|
|
3281
|
+
});
|
|
3282
|
+
}
|
|
3283
|
+
const status = { status: data.httpStatus };
|
|
3284
|
+
const structuredLifecycleCode = WORKSPACE_LIFECYCLE_ERROR_CODES.find((candidate) => candidate === data.workspaceLifecycleCode);
|
|
3285
|
+
if (structuredLifecycleCode) {
|
|
3286
|
+
return new WorkspaceLifecycleError(structuredLifecycleCode, error.message, status);
|
|
3287
|
+
}
|
|
3288
|
+
if (error.message.startsWith("INPUT_NOT_PENDING:"))
|
|
3289
|
+
return new GittermError("INPUT_NOT_PENDING", error.message, status);
|
|
3290
|
+
const credentialCode = credentialErrorCode(error.message);
|
|
3291
|
+
if (credentialCode)
|
|
3292
|
+
return new GittermError(credentialCode, error.message, status);
|
|
3293
|
+
const lifecycleCode = WORKSPACE_LIFECYCLE_ERROR_CODES.find((candidate) => error.message.startsWith(`${candidate}:`));
|
|
3294
|
+
if (lifecycleCode)
|
|
3295
|
+
return new WorkspaceLifecycleError(lifecycleCode, error.message, status);
|
|
3296
|
+
const code = mapTrpcCode(data.code);
|
|
3297
|
+
return new GittermError(code, code === "UNAUTHORIZED" ? `${error.message}. Check your GitTerm API token, or create a new one in the dashboard.` : error.message, status);
|
|
3298
|
+
}
|
|
3299
|
+
return new GittermError("NETWORK", error instanceof Error ? error.message : "Network request failed", { cause: error });
|
|
3300
|
+
}
|
|
3301
|
+
async function runWithServer(serverUrl, operation) {
|
|
3302
|
+
try {
|
|
3303
|
+
return await operation();
|
|
3304
|
+
} catch (error) {
|
|
3305
|
+
throw toGittermError(serverUrl, error);
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
function isTerminalRun(status) {
|
|
3309
|
+
return status === "completed" || status === "failed" || status === "cancelled";
|
|
3310
|
+
}
|
|
3311
|
+
async function* watchRun(trpc, serverUrl, target, options) {
|
|
3312
|
+
const what = `watching run ${target.runId}`;
|
|
3313
|
+
throwIfAborted(options?.signal, what);
|
|
3314
|
+
const queue = [];
|
|
3315
|
+
let wake;
|
|
3316
|
+
const push = (item) => {
|
|
3317
|
+
queue.push(item);
|
|
3318
|
+
wake?.();
|
|
3319
|
+
wake = undefined;
|
|
3320
|
+
};
|
|
3321
|
+
const onAbort = () => push({ error: new GittermError("ABORTED", `Aborted while ${what}`) });
|
|
3322
|
+
options?.signal?.addEventListener("abort", onAbort, { once: true });
|
|
3323
|
+
let last;
|
|
3324
|
+
const subscription = trpc.run.lifecycle.subscribe(target, {
|
|
3325
|
+
onData: (event) => {
|
|
3326
|
+
last = event.run;
|
|
3327
|
+
push({ run: last });
|
|
3328
|
+
},
|
|
3329
|
+
onError: (error) => push({ error }),
|
|
3330
|
+
onComplete: () => push(last && isTerminalRun(last.status) ? { done: true } : {
|
|
3331
|
+
error: new GittermError("NETWORK", `Run ${target.runId} event stream ended unexpectedly`)
|
|
3332
|
+
})
|
|
3333
|
+
});
|
|
3334
|
+
try {
|
|
3335
|
+
while (true) {
|
|
3336
|
+
throwIfAborted(options?.signal, what);
|
|
3337
|
+
if (queue.length === 0)
|
|
3338
|
+
await new Promise((resolve) => wake = resolve);
|
|
3339
|
+
const item = queue.shift();
|
|
3340
|
+
if ("done" in item)
|
|
3341
|
+
return;
|
|
3342
|
+
if ("error" in item)
|
|
3343
|
+
throw toGittermError(serverUrl, item.error);
|
|
3344
|
+
yield item.run;
|
|
3345
|
+
if (isTerminalRun(item.run.status))
|
|
3346
|
+
return;
|
|
3347
|
+
}
|
|
3348
|
+
} finally {
|
|
3349
|
+
options?.signal?.removeEventListener("abort", onAbort);
|
|
3350
|
+
subscription.unsubscribe();
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
function createGittermClient(options = {}) {
|
|
3354
|
+
const credentials = resolveCredentials(options);
|
|
3355
|
+
const fetchImpl = createNoRedirectFetch(options.fetch);
|
|
3356
|
+
const authorization = `Bearer ${credentials.token}`;
|
|
3357
|
+
const trpc = createTRPCClient({
|
|
3358
|
+
links: [
|
|
3359
|
+
splitLink({
|
|
3360
|
+
condition: (operation) => operation.type === "subscription",
|
|
3361
|
+
true: httpSubscriptionLink({
|
|
3362
|
+
url: trpcEndpoint(credentials.serverUrl),
|
|
3363
|
+
EventSource,
|
|
3364
|
+
eventSourceOptions: {
|
|
3365
|
+
fetch: (url, init) => fetchImpl(url, { ...init, headers: { ...init.headers, authorization } })
|
|
3366
|
+
}
|
|
3367
|
+
}),
|
|
3368
|
+
false: httpBatchLink({
|
|
3369
|
+
url: trpcEndpoint(credentials.serverUrl),
|
|
3370
|
+
fetch: fetchImpl,
|
|
3371
|
+
headers: () => ({ authorization })
|
|
3372
|
+
})
|
|
3373
|
+
})
|
|
3374
|
+
]
|
|
3375
|
+
});
|
|
3376
|
+
const run = (operation) => runWithServer(credentials.serverUrl, operation);
|
|
3377
|
+
const watch = (ref, watchOptions) => watchRun(trpc, credentials.serverUrl, runTargetOf(ref), watchOptions);
|
|
3378
|
+
const respond = (ref, input) => run(async () => trpc.run.respond.mutate({ ...runTargetOf(ref), ...input }));
|
|
3379
|
+
const normalizeCreateResult = (result) => {
|
|
3380
|
+
const workspace = normalizeWorkspace(result.workspace);
|
|
3381
|
+
if (!workspace)
|
|
3382
|
+
throw new GittermError("SERVER_ERROR", "Workspace creation failed");
|
|
3383
|
+
const runtime = result.runtime ? normalizeRuntime(result.runtime) : {
|
|
3384
|
+
workspaceId: workspace.id,
|
|
3385
|
+
status: workspace.status,
|
|
3386
|
+
url: null,
|
|
3387
|
+
directory: "/workspace",
|
|
3388
|
+
repo: workspace.repositoryUrl,
|
|
3389
|
+
branch: workspace.repositoryBranch,
|
|
3390
|
+
baseCommit: workspace.baseCommit,
|
|
3391
|
+
checkoutRef: workspace.checkoutRef,
|
|
3392
|
+
persistent: workspace.persistent,
|
|
3393
|
+
recoverable: workspace.status !== "terminated",
|
|
3394
|
+
providerKey: null
|
|
3395
|
+
};
|
|
3396
|
+
return { workspace, runtime };
|
|
3397
|
+
};
|
|
3398
|
+
const createWorkspace = (input) => run(async () => {
|
|
3399
|
+
const result = await trpc.workspace.createWorkspace.mutate(input);
|
|
3400
|
+
return normalizeCreateResult(result);
|
|
3401
|
+
});
|
|
3402
|
+
const waitForWorkspaceSetup = async (workspaceId, waitOptions) => {
|
|
3403
|
+
const timeoutMs = waitOptions?.timeoutMs ?? 10 * 60000;
|
|
3404
|
+
const pollIntervalMs = waitOptions?.pollIntervalMs ?? 2000;
|
|
3405
|
+
const signal = waitOptions?.signal;
|
|
3406
|
+
const deadline = Date.now() + timeoutMs;
|
|
3407
|
+
while (true) {
|
|
3408
|
+
throwIfAborted(signal, "waiting for workspace setup");
|
|
3409
|
+
const result = await trpc.workspace.getSetupStatus.query({ workspaceId }, { signal });
|
|
3410
|
+
if (result.status === "not_requested" || result.status === "succeeded")
|
|
3411
|
+
return result;
|
|
3412
|
+
if (result.status === "failed") {
|
|
3413
|
+
const log = result.log?.trim();
|
|
3414
|
+
throw new GittermError("BAD_REQUEST", `Workspace setup failed${result.exitCode === null ? "" : ` with exit code ${result.exitCode}`}${log ? `
|
|
3415
|
+
${log}` : ""}`);
|
|
3416
|
+
}
|
|
3417
|
+
if (Date.now() >= deadline) {
|
|
3418
|
+
const log = result.log?.trim();
|
|
3419
|
+
throw new GittermError("TIMEOUT", `Timed out waiting for workspace ${workspaceId} setup (last status: ${result.status})${log ? `
|
|
3420
|
+
${log}` : ""}`);
|
|
3421
|
+
}
|
|
3422
|
+
await sleep3(pollIntervalMs, signal, "waiting for workspace setup");
|
|
3423
|
+
}
|
|
3424
|
+
};
|
|
3425
|
+
return {
|
|
3426
|
+
serverUrl: credentials.serverUrl,
|
|
3427
|
+
auth: {
|
|
3428
|
+
status: () => run(async () => {
|
|
3429
|
+
const me = await trpc.agent.me.query();
|
|
3430
|
+
return {
|
|
3431
|
+
loggedIn: true,
|
|
3432
|
+
userId: me.userId,
|
|
3433
|
+
email: me.email,
|
|
3434
|
+
name: me.name,
|
|
3435
|
+
plan: me.plan ?? "free",
|
|
3436
|
+
authMethod: me.authMethod
|
|
3437
|
+
};
|
|
3438
|
+
})
|
|
3439
|
+
},
|
|
3440
|
+
workspaces: {
|
|
3441
|
+
list: (input) => run(async () => {
|
|
3442
|
+
const result = await trpc.workspace.listWorkspaces.query(input);
|
|
3443
|
+
return {
|
|
3444
|
+
workspaces: result.workspaces.map(normalizeWorkspace).filter((workspace) => workspace !== null),
|
|
3445
|
+
pagination: result.pagination
|
|
3446
|
+
};
|
|
3447
|
+
}),
|
|
3448
|
+
get: (ref) => run(async () => {
|
|
3449
|
+
const result = await trpc.workspace.getWorkspace.query({
|
|
3450
|
+
workspaceId: workspaceIdOf(ref)
|
|
3451
|
+
});
|
|
3452
|
+
const workspace = normalizeWorkspace(result.workspace);
|
|
3453
|
+
if (!workspace)
|
|
3454
|
+
throw new GittermError("NOT_FOUND", "Workspace not found");
|
|
3455
|
+
return workspace;
|
|
3456
|
+
}),
|
|
3457
|
+
getRuntimeAccess: (ref) => run(async () => {
|
|
3458
|
+
const result = await trpc.workspace.getRuntimeAccess.query({
|
|
3459
|
+
workspaceId: workspaceIdOf(ref)
|
|
3460
|
+
});
|
|
3461
|
+
return normalizeRuntime(result);
|
|
3462
|
+
}),
|
|
3463
|
+
ensureRunning: (ref, waitOptions) => run(async () => {
|
|
3464
|
+
throwIfAborted(waitOptions?.signal, "ensuring the workspace is running");
|
|
3465
|
+
const result = await trpc.workspace.ensureRunning.mutate({
|
|
3466
|
+
workspaceId: workspaceIdOf(ref),
|
|
3467
|
+
timeoutMs: waitOptions?.timeoutMs,
|
|
3468
|
+
pollIntervalMs: waitOptions?.pollIntervalMs
|
|
3469
|
+
}, { signal: waitOptions?.signal });
|
|
3470
|
+
const workspace = normalizeWorkspace(result.workspace);
|
|
3471
|
+
if (!workspace)
|
|
3472
|
+
throw new GittermError("SERVER_ERROR", "ensureRunning failed");
|
|
3473
|
+
return {
|
|
3474
|
+
workspace,
|
|
3475
|
+
runtime: normalizeRuntime(result.runtime)
|
|
3476
|
+
};
|
|
3477
|
+
}),
|
|
3478
|
+
pause: (ref) => run(async () => {
|
|
3479
|
+
const result = await trpc.workspace.pauseWorkspace.mutate({
|
|
3480
|
+
workspaceId: workspaceIdOf(ref)
|
|
3481
|
+
});
|
|
3482
|
+
return { durationMinutes: result.durationMinutes };
|
|
3483
|
+
}),
|
|
3484
|
+
restart: (ref) => run(async () => {
|
|
3485
|
+
const result = await trpc.workspace.restartWorkspace.mutate({
|
|
3486
|
+
workspaceId: workspaceIdOf(ref)
|
|
3487
|
+
});
|
|
3488
|
+
return { status: result.status };
|
|
3489
|
+
}),
|
|
3490
|
+
terminate: (ref) => run(async () => {
|
|
3491
|
+
const result = await trpc.workspace.deleteWorkspace.mutate({
|
|
3492
|
+
workspaceId: workspaceIdOf(ref)
|
|
3493
|
+
});
|
|
3494
|
+
return {
|
|
3495
|
+
workspace: normalizeWorkspace(result.workspace),
|
|
3496
|
+
cleanupInBackground: result.cleanupInBackground
|
|
3497
|
+
};
|
|
3498
|
+
}),
|
|
3499
|
+
create: createWorkspace,
|
|
3500
|
+
models: (ref) => run(() => trpc.workspace.getModelAccess.query({ workspaceId: workspaceIdOf(ref) })),
|
|
3501
|
+
setupStatus: (ref) => run(async () => trpc.workspace.getSetupStatus.query({ workspaceId: workspaceIdOf(ref) })),
|
|
3502
|
+
waitForSetup: (ref, waitOptions) => run(() => waitForWorkspaceSetup(workspaceIdOf(ref), waitOptions))
|
|
3503
|
+
},
|
|
3504
|
+
sandboxes: {
|
|
3505
|
+
create: (input) => run(() => trpc.sandbox.create.mutate(input)),
|
|
3506
|
+
list: (input) => run(() => trpc.sandbox.list.query(input)),
|
|
3507
|
+
get: (sandboxId) => run(() => trpc.sandbox.get.query({ sandboxId })),
|
|
3508
|
+
run: (sandboxId, command, options) => run(async () => {
|
|
3509
|
+
const result = await trpc.sandbox.run.mutate({ sandboxId, command, ...options });
|
|
3510
|
+
if (!result)
|
|
3511
|
+
throw new GittermError("SERVER_ERROR", "Sandbox command returned no result");
|
|
3512
|
+
return result;
|
|
3513
|
+
}),
|
|
3514
|
+
writeFile: (sandboxId, path, data) => run(async () => {
|
|
3515
|
+
await trpc.sandbox.writeFile.mutate({
|
|
3516
|
+
sandboxId,
|
|
3517
|
+
path,
|
|
3518
|
+
contentBase64: Buffer.from(data).toString("base64")
|
|
3519
|
+
});
|
|
3520
|
+
}),
|
|
3521
|
+
readFile: (sandboxId, path) => run(async () => {
|
|
3522
|
+
const result = await trpc.sandbox.readFile.mutate({ sandboxId, path });
|
|
3523
|
+
return new Uint8Array(Buffer.from(result?.contentBase64 ?? "", "base64"));
|
|
3524
|
+
}),
|
|
3525
|
+
preview: (sandboxId, port, options) => run(async () => {
|
|
3526
|
+
const result = await trpc.sandbox.preview.mutate({
|
|
3527
|
+
sandboxId,
|
|
3528
|
+
port,
|
|
3529
|
+
public: options?.public
|
|
3530
|
+
});
|
|
3531
|
+
if (!result)
|
|
3532
|
+
throw new GittermError("SERVER_ERROR", "Sandbox preview returned no result");
|
|
3533
|
+
return result;
|
|
3534
|
+
}),
|
|
3535
|
+
resume: (sandboxId) => run(async () => {
|
|
3536
|
+
await trpc.workspace.restartWorkspace.mutate({ workspaceId: sandboxId });
|
|
3537
|
+
}),
|
|
3538
|
+
terminate: (sandboxId) => run(async () => {
|
|
3539
|
+
await trpc.sandbox.terminate.mutate({ sandboxId });
|
|
3540
|
+
}),
|
|
3541
|
+
saveSnapshot: (sandboxId, label) => run(() => trpc.sandbox.saveSnapshot.mutate({ sandboxId, label: label.slice(0, 120) })),
|
|
3542
|
+
listSnapshots: () => run(() => trpc.sandbox.snapshots.query()),
|
|
3543
|
+
deleteSnapshot: (snapshotId) => run(async () => {
|
|
3544
|
+
await trpc.sandbox.deleteSnapshot.mutate({ snapshotId });
|
|
3545
|
+
}),
|
|
3546
|
+
onJobFinished: (listener) => {
|
|
3547
|
+
const subscription = trpc.sandbox.jobEvents.subscribe(undefined, {
|
|
3548
|
+
onData: (event) => listener(event),
|
|
3549
|
+
onError: () => {}
|
|
3550
|
+
});
|
|
3551
|
+
return () => subscription.unsubscribe();
|
|
3552
|
+
}
|
|
3553
|
+
},
|
|
3554
|
+
runs: {
|
|
3555
|
+
create: (input) => run(async () => {
|
|
3556
|
+
const { signal, workspace, ...rest } = input;
|
|
3557
|
+
const request = {
|
|
3558
|
+
...rest,
|
|
3559
|
+
workspaceId: workspaceIdOf(workspace),
|
|
3560
|
+
context: rest.context?.type === "continue" ? { type: "continue", runId: rest.context.run.id } : rest.context
|
|
3561
|
+
};
|
|
3562
|
+
if (request.waitForSetup) {
|
|
3563
|
+
await waitForWorkspaceSetup(request.workspaceId, {
|
|
3564
|
+
timeoutMs: request.setupTimeoutMs,
|
|
3565
|
+
signal
|
|
3566
|
+
});
|
|
3567
|
+
}
|
|
3568
|
+
throwIfAborted(signal, "creating the run");
|
|
3569
|
+
return trpc.run.create.mutate({ ...request, idempotencyKey: request.idempotencyKey ?? crypto.randomUUID() }, { signal });
|
|
3570
|
+
}),
|
|
3571
|
+
list: (ref, listOptions) => run(async () => trpc.run.list.query({
|
|
3572
|
+
workspaceId: workspaceIdOf(ref),
|
|
3573
|
+
...listOptions
|
|
3574
|
+
})),
|
|
3575
|
+
get: (ref) => run(async () => trpc.run.get.query(runTargetOf(ref))),
|
|
3576
|
+
messages: (ref) => run(async () => {
|
|
3577
|
+
const messages = await trpc.run.messages.query(runTargetOf(ref));
|
|
3578
|
+
return messages.map((message) => ({ ...message, parts: message.parts ?? [] }));
|
|
3579
|
+
}),
|
|
3580
|
+
cancel: (ref) => run(async () => trpc.run.cancel.mutate(runTargetOf(ref))),
|
|
3581
|
+
respond,
|
|
3582
|
+
watch,
|
|
3583
|
+
...runHelpers({ watch, respond })
|
|
3584
|
+
},
|
|
3585
|
+
catalog: {
|
|
3586
|
+
agentTypes: (input) => run(async () => {
|
|
3587
|
+
const result = await trpc.workspace.listAgentTypes.query(input);
|
|
3588
|
+
return result.agentTypes;
|
|
3589
|
+
}),
|
|
3590
|
+
cloudProviders: (input) => run(async () => {
|
|
3591
|
+
const result = await trpc.workspace.listCloudProviders.query(input);
|
|
3592
|
+
return result.cloudProviders;
|
|
3593
|
+
}),
|
|
3594
|
+
workspaceOptions: () => run(async () => trpc.workspace.getWorkspaceCatalog.query())
|
|
3595
|
+
},
|
|
3596
|
+
integrations: {
|
|
3597
|
+
catalog: () => run(async () => trpc.integrations.catalog.query()),
|
|
3598
|
+
connections: {
|
|
3599
|
+
list: (filter) => run(async () => {
|
|
3600
|
+
const result = await trpc.integrations.connections.list.query(filter);
|
|
3601
|
+
return result.map(toConnection);
|
|
3602
|
+
}),
|
|
3603
|
+
get: (id) => run(async () => toConnection(await trpc.integrations.connections.get.query({ id }))),
|
|
3604
|
+
resolve: (references, options) => run(async () => {
|
|
3605
|
+
const result = await trpc.integrations.connections.resolve.query({
|
|
3606
|
+
references,
|
|
3607
|
+
...options?.repo ? { repo: options.repo } : {}
|
|
3608
|
+
});
|
|
3609
|
+
return result.map(toConnection);
|
|
3610
|
+
}),
|
|
3611
|
+
create: (input) => run(async () => {
|
|
3612
|
+
const result = await trpc.integrations.connections.create.mutate(input);
|
|
3613
|
+
return result.status !== "pending" ? { ...result, connection: toConnection(result.connection) } : result;
|
|
3614
|
+
}),
|
|
3615
|
+
remove: (id) => run(async () => {
|
|
3616
|
+
await trpc.integrations.connections.remove.mutate({ id });
|
|
3617
|
+
}),
|
|
3618
|
+
waitFor: ({
|
|
3619
|
+
integration,
|
|
3620
|
+
since,
|
|
3621
|
+
connectionId,
|
|
3622
|
+
timeoutMs = 5 * 60000,
|
|
3623
|
+
intervalMs = 3000
|
|
3624
|
+
}) => run(async () => {
|
|
3625
|
+
const sinceMs = since ? new Date(since).getTime() : 0;
|
|
3626
|
+
const deadline = Date.now() + timeoutMs;
|
|
3627
|
+
while (Date.now() < deadline) {
|
|
3628
|
+
const result = await trpc.integrations.connections.list.query({
|
|
3629
|
+
integration,
|
|
3630
|
+
kind: "personal"
|
|
3631
|
+
});
|
|
3632
|
+
const match = result.map(toConnection).filter((c) => c.status === "connected" && (connectionId ? c.id === connectionId : new Date(c.connectedAt).getTime() >= sinceMs)).sort((a, b) => b.connectedAt.localeCompare(a.connectedAt))[0];
|
|
3633
|
+
if (match)
|
|
3634
|
+
return match;
|
|
3635
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
3636
|
+
}
|
|
3637
|
+
throw new Error(`Timed out waiting for a ${integration} connection`);
|
|
3638
|
+
})
|
|
3639
|
+
},
|
|
3640
|
+
github: {
|
|
3641
|
+
repositories: (connectionId) => run(async () => trpc.integrations.github.repositories.query({ connectionId })),
|
|
3642
|
+
branches: (connectionId, owner, repo) => run(async () => trpc.integrations.github.branches.query({ connectionId, owner, repo }))
|
|
3643
|
+
},
|
|
3644
|
+
google: {
|
|
3645
|
+
setup: () => run(async () => trpc.integrations.google.setup.query())
|
|
3646
|
+
},
|
|
3647
|
+
mcp: {
|
|
3648
|
+
test: (id) => run(async () => {
|
|
3649
|
+
const result = await trpc.integrations.mcp.test.mutate({ id });
|
|
3650
|
+
return { ...result, connection: toConnection(result.connection) };
|
|
3651
|
+
}),
|
|
3652
|
+
update: (input) => run(async () => toConnection(await trpc.integrations.mcp.update.mutate(input)))
|
|
3653
|
+
}
|
|
3654
|
+
},
|
|
3655
|
+
bots: {
|
|
3656
|
+
acquireLease: (leaseId) => run(() => trpc.bots.acquireLease.mutate({ leaseId })),
|
|
3657
|
+
releaseLease: (leaseId) => run(() => trpc.bots.releaseLease.mutate({ leaseId })),
|
|
3658
|
+
self: () => run(async () => trpc.bots.self.query())
|
|
3659
|
+
},
|
|
3660
|
+
credentials: {
|
|
3661
|
+
list: () => run(async () => {
|
|
3662
|
+
const result = await trpc.modelCredentials.listMyCredentials.query();
|
|
3663
|
+
return result.credentials.map((credential) => ({
|
|
3664
|
+
...credential,
|
|
3665
|
+
lastUsedAt: toIso(credential.lastUsedAt),
|
|
3666
|
+
oauthExpiresAt: toIso(credential.oauthExpiresAt),
|
|
3667
|
+
createdAt: toIso(credential.createdAt),
|
|
3668
|
+
updatedAt: toIso(credential.updatedAt)
|
|
3669
|
+
}));
|
|
3670
|
+
}),
|
|
3671
|
+
listProviders: () => run(async () => {
|
|
3672
|
+
const result = await trpc.modelCredentials.listProviders.query();
|
|
3673
|
+
return result.providers.map((provider) => ({
|
|
3674
|
+
id: provider.id,
|
|
3675
|
+
name: provider.name,
|
|
3676
|
+
logicalProviderKey: provider.logicalProviderKey,
|
|
3677
|
+
displayName: provider.displayName,
|
|
3678
|
+
authType: provider.authType,
|
|
3679
|
+
isRecommended: provider.isRecommended
|
|
3680
|
+
}));
|
|
3681
|
+
})
|
|
3682
|
+
}
|
|
3683
|
+
};
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3686
|
+
// src/sandbox/config.ts
|
|
3687
|
+
var DIRECT_PROVIDERS = ["e2b", "daytona", "vercel", "boat", "exedev", "railway"];
|
|
3688
|
+
var PROVIDER_NAMES = {
|
|
3689
|
+
e2b: "E2B",
|
|
3690
|
+
daytona: "Daytona",
|
|
3691
|
+
vercel: "Vercel",
|
|
3692
|
+
boat: "Boat",
|
|
3693
|
+
exedev: "exe.dev",
|
|
3694
|
+
railway: "Railway",
|
|
3695
|
+
aws: "AWS",
|
|
3696
|
+
gitterm: "Gitterm"
|
|
3697
|
+
};
|
|
3698
|
+
function selected(settings, accountName) {
|
|
3699
|
+
const name = accountName ?? settings.account;
|
|
3700
|
+
const accounts = settings.accounts ?? {};
|
|
3701
|
+
if (!name) {
|
|
3702
|
+
const names = Object.keys(accounts);
|
|
3703
|
+
throw new Error(names.length ? `No sandbox account selected. Choose one of: ${names.join(", ")}.` : "No sandbox accounts are configured. Add one to the sandbox settings.");
|
|
3704
|
+
}
|
|
3705
|
+
const config = accounts[validateAccountName(name)];
|
|
3706
|
+
if (!config)
|
|
3707
|
+
throw new Error(`Sandbox account ${JSON.stringify(name)} is not configured.`);
|
|
3708
|
+
const mode = config.mode;
|
|
3709
|
+
if (mode !== "direct" && mode !== "managed")
|
|
3710
|
+
throw new Error(`Sandbox account ${name} has an unknown mode.`);
|
|
3711
|
+
const provider = config.provider ?? (config.mode === "managed" && config.size ? "gitterm" : "e2b");
|
|
3712
|
+
if (config.mode === "managed" && config.size && provider !== "gitterm")
|
|
3713
|
+
throw new Error(`Sandbox account ${name}: "size" picks a Gitterm size; drop "provider" or use "machine".`);
|
|
3714
|
+
if (!Object.hasOwn(SANDBOX_PROVIDER_PROFILES, provider))
|
|
3715
|
+
throw new Error(`Sandbox account ${name} uses provider ${JSON.stringify(provider)}, which does not support sandboxes.`);
|
|
3716
|
+
if (mode === "direct" && !DIRECT_PROVIDERS.includes(provider))
|
|
3717
|
+
throw new Error(`Sandbox account ${name}: ${provider} sandboxes are available only as a managed account.`);
|
|
3718
|
+
return { name, config, provider };
|
|
3719
|
+
}
|
|
3720
|
+
function directCredential(config) {
|
|
3721
|
+
if (config.apiKey !== undefined)
|
|
3722
|
+
return { source: "plugin option", value: config.apiKey.trim() };
|
|
3723
|
+
if (config.apiKeyEnv)
|
|
3724
|
+
return {
|
|
3725
|
+
source: `environment variable ${config.apiKeyEnv}`,
|
|
3726
|
+
value: process.env[config.apiKeyEnv]?.trim() ?? ""
|
|
3727
|
+
};
|
|
3728
|
+
return { source: "not configured", value: "" };
|
|
3729
|
+
}
|
|
3730
|
+
function describeLimitations(provider, mode) {
|
|
3731
|
+
const capabilities = mode === "managed" ? managedCapabilities(provider) : SANDBOX_PROVIDER_PROFILES[provider].capabilities;
|
|
3732
|
+
const name = PROVIDER_NAMES[provider];
|
|
3733
|
+
return [
|
|
3734
|
+
mode === "managed" ? "Gitterm terminates the sandbox at its lifetime, even if this machine is offline." : capabilities.expiry === "hard" ? `${name} deletes the sandbox at its lifetime, even if this machine is offline.` : capabilities.expiry === "idle" ? `${name} deletes the sandbox after it has been idle for its lifetime; an actively used sandbox can run longer.` : capabilities.expiry === "archive" ? `${name} stops and archives the sandbox at its lifetime; the archived storage remains until you destroy it.` : `${name} never deletes an abandoned sandbox. Destroy it yourself; a crash leaves it running and billing.`,
|
|
3735
|
+
capabilities.previews === "either" ? mode === "managed" ? "Gitterm serves previews: private to your Gitterm login unless created with public previews." : "Previews are private unless created with public previews." : capabilities.previews === "private" ? `${name} previews are always private: they need an access header.` : capabilities.previews === "public" ? `${name} previews are always public URLs, so they need the public-sharing approval.` : `${name} sandboxes cannot expose ports.`,
|
|
3736
|
+
...capabilities.pause === "state-losing" ? [`Pausing a ${name} sandbox discards its files and processes.`] : []
|
|
3737
|
+
];
|
|
3738
|
+
}
|
|
3739
|
+
function describeMachine(config) {
|
|
3740
|
+
if (config.mode === "managed") {
|
|
3741
|
+
const machine = config.size ?? config.machine;
|
|
3742
|
+
return machine ? `Gitterm machine profile "${machine}".` : "Your Gitterm plan's default machine profile.";
|
|
3743
|
+
}
|
|
3744
|
+
switch (config.provider) {
|
|
3745
|
+
case "e2b":
|
|
3746
|
+
if (config.snapshot)
|
|
3747
|
+
return `E2B snapshot "${config.snapshot}" (sized as when it was saved).`;
|
|
3748
|
+
return config.template ? `E2B template "${config.template}" (its size is fixed when the template is built).` : "E2B base template: 2 vCPU, 512 MB memory.";
|
|
3749
|
+
case "daytona": {
|
|
3750
|
+
if (config.snapshot)
|
|
3751
|
+
return `Daytona snapshot "${config.snapshot}" (sized by the snapshot).`;
|
|
3752
|
+
const { cpu = 1, memory = 1, disk = 3 } = config.resources ?? {};
|
|
3753
|
+
return `${cpu} vCPU, ${memory} GiB memory, ${disk} GiB disk.`;
|
|
3754
|
+
}
|
|
3755
|
+
case "vercel": {
|
|
3756
|
+
const vcpus = config.vcpus ?? 2;
|
|
3757
|
+
return `${vcpus} vCPU, ${vcpus * 2} GB memory${config.snapshot ? `, from snapshot "${config.snapshot}"` : ""}.`;
|
|
3758
|
+
}
|
|
3759
|
+
case "boat":
|
|
3760
|
+
return `Boat size "${config.size ?? "default"}"${config.snapshot ? `, from snapshot "${config.snapshot}"` : ""}.`;
|
|
3761
|
+
case "exedev":
|
|
3762
|
+
return config.cpu || config.memory || config.disk ? `${[config.cpu && `${config.cpu} vCPU`, config.memory && `${config.memory} memory`, config.disk && `${config.disk} disk`].filter(Boolean).join(", ")}.` : "exe.dev's default VM size.";
|
|
3763
|
+
case "railway":
|
|
3764
|
+
return "Railway's per-service limits for your Railway plan.";
|
|
3765
|
+
}
|
|
3766
|
+
}
|
|
3767
|
+
function describeSandboxAccount(settings, accountName) {
|
|
3768
|
+
const { name, config, provider } = selected(settings, accountName);
|
|
3769
|
+
const base = {
|
|
3770
|
+
name,
|
|
3771
|
+
provider,
|
|
3772
|
+
capabilities: config.mode === "managed" ? managedCapabilities(provider) : SANDBOX_PROVIDER_PROFILES[provider].capabilities,
|
|
3773
|
+
limitations: describeLimitations(provider, config.mode),
|
|
3774
|
+
pinnedSnapshot: config.mode === "direct" && "snapshot" in config ? config.snapshot ?? null : null,
|
|
3775
|
+
machine: `${describeMachine(config)} Only the user can change this, in the settings of sandbox account "${name}".`
|
|
3776
|
+
};
|
|
3777
|
+
if (config.mode === "direct") {
|
|
3778
|
+
const credential = directCredential(config);
|
|
3779
|
+
return {
|
|
3780
|
+
...base,
|
|
3781
|
+
mode: "direct",
|
|
3782
|
+
credentialSource: credential.source,
|
|
3783
|
+
credentialPresent: Boolean(credential.value),
|
|
3784
|
+
chargedTo: `your ${PROVIDER_NAMES[provider]} account (no Gitterm account or credits used)`
|
|
3785
|
+
};
|
|
3786
|
+
}
|
|
3787
|
+
return {
|
|
3788
|
+
...base,
|
|
3789
|
+
mode: "managed",
|
|
3790
|
+
credentialSource: config.token ? "plugin option" : "Gitterm CLI login or GITTERM_API_TOKEN",
|
|
3791
|
+
credentialPresent: true,
|
|
3792
|
+
chargedTo: "your Gitterm plan (usage, quotas, and limits apply)"
|
|
3793
|
+
};
|
|
3794
|
+
}
|
|
3795
|
+
function createSandboxBackend(settings, accountName) {
|
|
3796
|
+
const { name, config, provider } = selected(settings, accountName);
|
|
3797
|
+
if (config.mode === "managed")
|
|
3798
|
+
return createManagedSandboxBackend({
|
|
3799
|
+
account: name,
|
|
3800
|
+
provider,
|
|
3801
|
+
client: createGittermClient({ serverUrl: config.serverUrl, token: config.token }),
|
|
3802
|
+
machine: config.size ?? config.machine
|
|
3803
|
+
});
|
|
3804
|
+
const credential = directCredential(config);
|
|
3805
|
+
if (!credential.value)
|
|
3806
|
+
throw new Error(`Sandbox account ${name} has no ${PROVIDER_NAMES[provider]} credential (${credential.source}). In OpenCode use "apiKey": "{env:YOUR_VARIABLE}" and make sure the variable is set.`);
|
|
3807
|
+
if (config.provider === "daytona" && config.snapshot && config.resources)
|
|
3808
|
+
throw new Error(`Sandbox account ${name}: a Daytona snapshot carries its own size, so set "snapshot" or "resources", not both.`);
|
|
3809
|
+
const { apiKey: _apiKey, apiKeyEnv: _apiKeyEnv, allowUnboundedLifetime, ...options } = config;
|
|
3810
|
+
return withAuthHint(createDirectSandboxBackend({
|
|
3811
|
+
account: name,
|
|
3812
|
+
allowUnboundedLifetime,
|
|
3813
|
+
transport: createSandboxTransport({ ...options, apiKey: credential.value })
|
|
3814
|
+
}), `This is your own ${PROVIDER_NAMES[provider]} key, not Gitterm's: check sandbox account "${name}" in your settings.`);
|
|
3815
|
+
}
|
|
3816
|
+
function withAuthHint(backend, hint) {
|
|
3817
|
+
return new Proxy(backend, {
|
|
3818
|
+
get(target, key, receiver) {
|
|
3819
|
+
const value = Reflect.get(target, key, receiver);
|
|
3820
|
+
if (typeof value !== "function")
|
|
3821
|
+
return value;
|
|
3822
|
+
return async (...args) => {
|
|
3823
|
+
try {
|
|
3824
|
+
return await value.apply(target, args);
|
|
3825
|
+
} catch (error) {
|
|
3826
|
+
if (isSandboxProviderError(error) && error.code === "auth")
|
|
3827
|
+
error.message = `${error.message} ${hint}`;
|
|
3828
|
+
throw error;
|
|
3829
|
+
}
|
|
3830
|
+
};
|
|
3831
|
+
}
|
|
3832
|
+
});
|
|
3833
|
+
}
|
|
3834
|
+
// src/sandbox/proxy.ts
|
|
3835
|
+
import { randomBytes as randomBytes9, timingSafeEqual } from "node:crypto";
|
|
3836
|
+
import { createServer } from "node:http";
|
|
3837
|
+
import { Readable } from "node:stream";
|
|
3838
|
+
var TOKEN_PARAM = "gitterm_preview_token";
|
|
3839
|
+
var COOKIE = "gitterm_preview";
|
|
3840
|
+
var TOKEN_FORMAT = /^[0-9a-f]{48}$/;
|
|
3841
|
+
var HOP_BY_HOP = new Set([
|
|
3842
|
+
"connection",
|
|
3843
|
+
"keep-alive",
|
|
3844
|
+
"proxy-authenticate",
|
|
3845
|
+
"proxy-authorization",
|
|
3846
|
+
"te",
|
|
3847
|
+
"trailer",
|
|
3848
|
+
"transfer-encoding",
|
|
3849
|
+
"upgrade",
|
|
3850
|
+
"host",
|
|
3851
|
+
"cookie"
|
|
3852
|
+
]);
|
|
3853
|
+
function cookies(header) {
|
|
3854
|
+
const result = new Map;
|
|
3855
|
+
for (const part of (header ?? "").split(";")) {
|
|
3856
|
+
const index = part.indexOf("=");
|
|
3857
|
+
if (index > 0)
|
|
3858
|
+
result.set(part.slice(0, index).trim(), part.slice(index + 1).trim());
|
|
3859
|
+
}
|
|
3860
|
+
return result;
|
|
3861
|
+
}
|
|
3862
|
+
async function startPreviewProxy(access) {
|
|
3863
|
+
const upstream = new URL(access.url);
|
|
3864
|
+
const token = randomBytes9(24).toString("hex");
|
|
3865
|
+
const hostname = `p-${randomBytes9(8).toString("hex")}.localhost`;
|
|
3866
|
+
const matches = (value) => !!value && TOKEN_FORMAT.test(value) && timingSafeEqual(Buffer.from(value), Buffer.from(token));
|
|
3867
|
+
const { cookie: accessCookie, ...accessHeaders } = access.headers;
|
|
3868
|
+
let expectedHost = "";
|
|
3869
|
+
async function handle(request, response) {
|
|
3870
|
+
if (request.headers.host?.toLowerCase() !== expectedHost) {
|
|
3871
|
+
response.writeHead(421).end();
|
|
3872
|
+
return;
|
|
3873
|
+
}
|
|
3874
|
+
const local = new URL(request.url ?? "/", `http://${expectedHost}`);
|
|
3875
|
+
const jar = cookies(request.headers.cookie);
|
|
3876
|
+
if (matches(local.searchParams.get(TOKEN_PARAM))) {
|
|
3877
|
+
local.searchParams.delete(TOKEN_PARAM);
|
|
3878
|
+
response.writeHead(303, {
|
|
3879
|
+
"set-cookie": `${COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/`,
|
|
3880
|
+
location: `${local.pathname}${local.search}`
|
|
3881
|
+
});
|
|
3882
|
+
response.end();
|
|
3883
|
+
return;
|
|
3884
|
+
}
|
|
3885
|
+
if (!matches(jar.get(COOKIE))) {
|
|
3886
|
+
response.writeHead(401).end();
|
|
3887
|
+
return;
|
|
3888
|
+
}
|
|
3889
|
+
jar.delete(COOKIE);
|
|
3890
|
+
const target = new URL(upstream.href);
|
|
3891
|
+
target.pathname = local.pathname;
|
|
3892
|
+
target.search = local.search;
|
|
3893
|
+
if (target.origin !== upstream.origin) {
|
|
3894
|
+
response.writeHead(400).end();
|
|
3895
|
+
return;
|
|
3896
|
+
}
|
|
3897
|
+
const headers = new Headers;
|
|
3898
|
+
for (const [key, value] of Object.entries(request.headers)) {
|
|
3899
|
+
if (value === undefined || HOP_BY_HOP.has(key.toLowerCase()))
|
|
3900
|
+
continue;
|
|
3901
|
+
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
|
|
3902
|
+
}
|
|
3903
|
+
for (const [key, value] of Object.entries(accessHeaders))
|
|
3904
|
+
headers.set(key, value);
|
|
3905
|
+
const forwarded = [...[...jar].map(([name, value]) => `${name}=${value}`), accessCookie].filter(Boolean);
|
|
3906
|
+
if (forwarded.length)
|
|
3907
|
+
headers.set("cookie", forwarded.join("; "));
|
|
3908
|
+
const body = request.method === "GET" || request.method === "HEAD" ? undefined : Readable.toWeb(request);
|
|
3909
|
+
const upstreamResponse = await fetch(target, {
|
|
3910
|
+
method: request.method,
|
|
3911
|
+
headers,
|
|
3912
|
+
body,
|
|
3913
|
+
redirect: "manual",
|
|
3914
|
+
duplex: "half"
|
|
3915
|
+
});
|
|
3916
|
+
const responseHeaders = {};
|
|
3917
|
+
upstreamResponse.headers.forEach((value, key) => {
|
|
3918
|
+
if (!HOP_BY_HOP.has(key) && key !== "content-encoding" && key !== "content-length" && key !== "set-cookie")
|
|
3919
|
+
responseHeaders[key] = value;
|
|
3920
|
+
});
|
|
3921
|
+
const setCookies = upstreamResponse.headers.getSetCookie();
|
|
3922
|
+
if (setCookies.length)
|
|
3923
|
+
responseHeaders["set-cookie"] = setCookies.map((cookie) => cookie.split(";").filter((part) => !/^\s*domain=/i.test(part)).join(";"));
|
|
3924
|
+
const location = upstreamResponse.headers.get("location");
|
|
3925
|
+
if (location) {
|
|
3926
|
+
const resolved = new URL(location, target);
|
|
3927
|
+
if (resolved.origin === upstream.origin)
|
|
3928
|
+
responseHeaders.location = `${resolved.pathname}${resolved.search}${resolved.hash}`;
|
|
3929
|
+
}
|
|
3930
|
+
response.writeHead(upstreamResponse.status, responseHeaders);
|
|
3931
|
+
if (upstreamResponse.body)
|
|
3932
|
+
Readable.fromWeb(upstreamResponse.body).pipe(response);
|
|
3933
|
+
else
|
|
3934
|
+
response.end();
|
|
3935
|
+
}
|
|
3936
|
+
const server = createServer((request, response) => {
|
|
3937
|
+
handle(request, response).catch(() => {
|
|
3938
|
+
if (!response.headersSent)
|
|
3939
|
+
response.writeHead(502);
|
|
3940
|
+
response.end();
|
|
3941
|
+
});
|
|
3942
|
+
});
|
|
3943
|
+
server.on("clientError", (_error, socket) => socket.destroy());
|
|
3944
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
3945
|
+
const { port } = server.address();
|
|
3946
|
+
expectedHost = `${hostname}:${port}`;
|
|
3947
|
+
return {
|
|
3948
|
+
url: `http://${expectedHost}/?${TOKEN_PARAM}=${token}`,
|
|
3949
|
+
close: () => new Promise((resolve) => server.close(() => resolve()))
|
|
3950
|
+
};
|
|
3951
|
+
}
|
|
3952
|
+
export {
|
|
3953
|
+
ArtifactConflictError,
|
|
3954
|
+
DEFAULT_LOG_TAIL_BYTES,
|
|
3955
|
+
DEFAULT_SANDBOX_LIMITS,
|
|
3956
|
+
MAX_SOURCE_BYTES,
|
|
3957
|
+
SandboxAccountError,
|
|
3958
|
+
applyArtifact,
|
|
3959
|
+
artifactDirectory,
|
|
3960
|
+
captureSource,
|
|
3961
|
+
createFileSandboxStore,
|
|
3962
|
+
createFileSetupSnapshotStore,
|
|
3963
|
+
createManagedSandboxBackend,
|
|
3964
|
+
createSandboxBackend,
|
|
3965
|
+
createSandboxClient,
|
|
3966
|
+
defaultStoreDirectory,
|
|
3967
|
+
describeSandboxAccount,
|
|
3968
|
+
isSandboxProviderError,
|
|
3969
|
+
jobRoot,
|
|
3970
|
+
projectDir,
|
|
3971
|
+
sanitizeOutput,
|
|
3972
|
+
sensitivePath,
|
|
3973
|
+
startPreviewProxy
|
|
3974
|
+
};
|