@decocms/sandbox-controller 0.5.0 → 0.6.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 +3 -0
- package/dist/chunk-QGM4M3NI.js +37 -0
- package/dist/index.d.ts +177 -3
- package/dist/index.js +3147 -236
- package/dist/wrapper-GH5LMTDE.js +3705 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import "./chunk-QGM4M3NI.js";
|
|
2
|
+
|
|
1
3
|
// index.ts
|
|
2
4
|
import { KubeConfig } from "@kubernetes/client-node";
|
|
3
5
|
|
|
@@ -24,16 +26,16 @@ function delay(ms, options = {}) {
|
|
|
24
26
|
});
|
|
25
27
|
}
|
|
26
28
|
var I32_MAX = 2 ** 31 - 1;
|
|
27
|
-
function setArbitraryLengthTimeout(callback,
|
|
28
|
-
|
|
29
|
-
if (
|
|
30
|
-
const id2 = Number(setTimeout(callback,
|
|
29
|
+
function setArbitraryLengthTimeout(callback, delay3) {
|
|
30
|
+
delay3 = Math.trunc(Math.max(delay3, 0) || 0);
|
|
31
|
+
if (delay3 <= I32_MAX) {
|
|
32
|
+
const id2 = Number(setTimeout(callback, delay3));
|
|
31
33
|
return { valueOf: () => id2 };
|
|
32
34
|
}
|
|
33
35
|
const start = Date.now();
|
|
34
36
|
let timeoutId;
|
|
35
37
|
const queueTimeout = () => {
|
|
36
|
-
const currentDelay =
|
|
38
|
+
const currentDelay = delay3 - (Date.now() - start);
|
|
37
39
|
timeoutId = currentDelay > I32_MAX ? Number(setTimeout(queueTimeout, I32_MAX)) : Number(setTimeout(callback, currentDelay));
|
|
38
40
|
};
|
|
39
41
|
queueTimeout();
|
|
@@ -3650,7 +3652,8 @@ var AgentSandboxProvider = class {
|
|
|
3650
3652
|
handle: rec.handle,
|
|
3651
3653
|
workdir: rec.workdir,
|
|
3652
3654
|
previewUrl: this.composePreviewUrl(rec),
|
|
3653
|
-
warmPoolAdopted: rec.tenantPoolPodBound === true
|
|
3655
|
+
warmPoolAdopted: rec.tenantPoolPodBound === true,
|
|
3656
|
+
provider: "kubernetes"
|
|
3654
3657
|
};
|
|
3655
3658
|
}
|
|
3656
3659
|
// ---- Persistence ----------------------------------------------------------
|
|
@@ -4461,239 +4464,3144 @@ function tenantPoolReader(store, cluster, { refreshMs = DEFAULT_TENANT_POOL_REFR
|
|
|
4461
4464
|
};
|
|
4462
4465
|
}
|
|
4463
4466
|
|
|
4464
|
-
// ../sandbox/server/provider/
|
|
4465
|
-
import {
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4467
|
+
// ../sandbox/server/provider/freestyle.ts
|
|
4468
|
+
import { createHash as createHash3, randomBytes as randomBytes2 } from "crypto";
|
|
4469
|
+
|
|
4470
|
+
// ../../node_modules/.bun/freestyle@0.2.16/node_modules/freestyle/dist/errors.js
|
|
4471
|
+
var FreestyleApiError = class _FreestyleApiError extends Error {
|
|
4472
|
+
/** A stable identifier for the failure, e.g. `"NOT_FOUND"`. */
|
|
4473
|
+
code;
|
|
4474
|
+
/** The HTTP status code of the response. */
|
|
4475
|
+
status;
|
|
4476
|
+
/** The request path that failed, for debugging. */
|
|
4477
|
+
path;
|
|
4478
|
+
constructor(status, body, path) {
|
|
4479
|
+
super(body.message || `Freestyle API error: ${body.code}`);
|
|
4480
|
+
this.name = "FreestyleApiError";
|
|
4481
|
+
this.code = body.code;
|
|
4482
|
+
this.status = status;
|
|
4483
|
+
this.path = path;
|
|
4484
|
+
Object.setPrototypeOf(this, _FreestyleApiError.prototype);
|
|
4485
|
+
}
|
|
4475
4486
|
};
|
|
4476
|
-
var SANDBOX_WATCH_PATH = "/api/sandbox/watch";
|
|
4477
|
-
var SANDBOX_WATCH_KEEPALIVE_MS = 15e3;
|
|
4478
|
-
var id = z4.string().min(1).max(512);
|
|
4479
|
-
var CLONE_URL_MAX = 4096;
|
|
4480
|
-
var ORG_FS_CONFIG_MAX = 65536;
|
|
4481
|
-
var expiresAt = z4.number().int().positive();
|
|
4482
|
-
var sandboxIdSchema = z4.object({
|
|
4483
|
-
userId: id,
|
|
4484
|
-
projectRef: id
|
|
4485
|
-
});
|
|
4486
|
-
var tenantSchema = z4.object({
|
|
4487
|
-
orgId: id,
|
|
4488
|
-
userId: id,
|
|
4489
|
-
orgSlug: z4.string().optional(),
|
|
4490
|
-
orgName: z4.string().optional(),
|
|
4491
|
-
userEmail: z4.string().optional(),
|
|
4492
|
-
userName: z4.string().optional()
|
|
4493
|
-
});
|
|
4494
|
-
var repoSchema = z4.object({
|
|
4495
|
-
cloneUrl: z4.string().min(1).max(CLONE_URL_MAX),
|
|
4496
|
-
connectionId: z4.string().optional(),
|
|
4497
|
-
repositoryId: z4.string().optional(),
|
|
4498
|
-
userName: z4.string(),
|
|
4499
|
-
userEmail: z4.string(),
|
|
4500
|
-
branch: z4.string().optional(),
|
|
4501
|
-
displayName: z4.string().optional(),
|
|
4502
|
-
submoduleCredentials: z4.array(z4.object({ host: z4.string(), token: z4.string() })).optional(),
|
|
4503
|
-
directoryName: z4.string().optional(),
|
|
4504
|
-
credentialExpiresAt: expiresAt.optional()
|
|
4505
|
-
});
|
|
4506
|
-
var ensureOptionsSchema = z4.object({
|
|
4507
|
-
purpose: z4.enum(["interactive", "harness-run"]).optional(),
|
|
4508
|
-
sandboxImage: SandboxImageSchema.optional(),
|
|
4509
|
-
branch: z4.string().optional(),
|
|
4510
|
-
repo: repoSchema.optional(),
|
|
4511
|
-
extraRepos: z4.array(repoSchema).optional(),
|
|
4512
|
-
workload: z4.object({
|
|
4513
|
-
runtime: z4.enum(["node", "bun", "deno"]),
|
|
4514
|
-
packageManager: z4.enum(["npm", "pnpm", "yarn", "bun", "deno"]),
|
|
4515
|
-
devPort: z4.number().int().optional(),
|
|
4516
|
-
packageManagerPath: z4.string().optional()
|
|
4517
|
-
}).optional(),
|
|
4518
|
-
cloneOnly: z4.boolean().optional(),
|
|
4519
|
-
env: z4.record(z4.string(), z4.string()).optional(),
|
|
4520
|
-
tenant: tenantSchema.optional(),
|
|
4521
|
-
orgFsConfigJson: z4.string().max(ORG_FS_CONFIG_MAX).optional()
|
|
4522
|
-
});
|
|
4523
|
-
var daemonSchema = z4.object({
|
|
4524
|
-
url: z4.url({ protocol: /^https?$/ }),
|
|
4525
|
-
token: z4.string().min(1)
|
|
4526
|
-
});
|
|
4527
|
-
var ensureInputSchema = z4.object({
|
|
4528
|
-
id: sandboxIdSchema,
|
|
4529
|
-
opts: ensureOptionsSchema,
|
|
4530
|
-
/**
|
|
4531
|
-
* Until when `opts.orgFsConfigJson` stays valid, at least; unset keeps it
|
|
4532
|
-
* out of the host's store. Clone credentials carry their own
|
|
4533
|
-
* `credentialExpiresAt`.
|
|
4534
|
-
*/
|
|
4535
|
-
credentialsValidUntil: z4.object({ orgFsConfig: expiresAt.optional() }).optional()
|
|
4536
|
-
});
|
|
4537
|
-
var ensureOutputSchema = z4.object({
|
|
4538
|
-
handle: z4.string().min(1),
|
|
4539
|
-
workdir: z4.string().min(1),
|
|
4540
|
-
previewUrl: z4.string().nullable(),
|
|
4541
|
-
warmPoolAdopted: z4.boolean(),
|
|
4542
|
-
daemon: daemonSchema
|
|
4543
|
-
});
|
|
4544
|
-
var handleInputSchema = z4.object({ handle: z4.string().min(1) });
|
|
4545
|
-
var statusInputSchema = z4.object({
|
|
4546
|
-
handle: z4.string().min(1),
|
|
4547
|
-
/** Re-provision an evicted claim before answering. */
|
|
4548
|
-
resurrect: z4.boolean().optional()
|
|
4549
|
-
});
|
|
4550
|
-
var podTerminationSchema = z4.object({
|
|
4551
|
-
reason: z4.string(),
|
|
4552
|
-
oomKilled: z4.boolean(),
|
|
4553
|
-
exitCode: z4.number().int().optional(),
|
|
4554
|
-
memoryLimit: z4.string().optional(),
|
|
4555
|
-
evictionMessage: z4.string().optional()
|
|
4556
|
-
});
|
|
4557
|
-
var statusOutputSchema = z4.object({
|
|
4558
|
-
alive: z4.boolean(),
|
|
4559
|
-
previewUrl: z4.string().nullable(),
|
|
4560
|
-
daemon: daemonSchema.nullable(),
|
|
4561
|
-
lastTermination: podTerminationSchema.nullable()
|
|
4562
|
-
});
|
|
4563
|
-
var lifetimeInputSchema = z4.object({
|
|
4564
|
-
handle: z4.string().min(1),
|
|
4565
|
-
graceMs: z4.number().int().min(0).optional()
|
|
4566
|
-
});
|
|
4567
|
-
var emptySchema = z4.object({});
|
|
4568
|
-
var capacityOutputSchema = z4.object({ schedulable: z4.boolean() });
|
|
4569
|
-
var tenantPoolsPushInputSchema = z4.object({
|
|
4570
|
-
repoUrl: z4.string().min(1).max(CLONE_URL_MAX),
|
|
4571
|
-
ref: z4.string().min(1)
|
|
4572
|
-
});
|
|
4573
|
-
var tenantPoolsPushOutputSchema = z4.object({
|
|
4574
|
-
pools: z4.array(z4.string())
|
|
4575
|
-
});
|
|
4576
|
-
var toolErrorSchema = z4.object({
|
|
4577
|
-
code: z4.enum(["bootstrap-rejected", "internal"]),
|
|
4578
|
-
error: z4.string(),
|
|
4579
|
-
status: z4.number().int().optional()
|
|
4580
|
-
});
|
|
4581
|
-
var since = z4.number();
|
|
4582
|
-
var claimPhaseSchema = z4.discriminatedUnion(
|
|
4583
|
-
"kind",
|
|
4584
|
-
[
|
|
4585
|
-
z4.object({ kind: z4.literal("claiming"), since }),
|
|
4586
|
-
z4.object({
|
|
4587
|
-
kind: z4.literal("waiting-for-capacity"),
|
|
4588
|
-
since,
|
|
4589
|
-
message: z4.string().optional(),
|
|
4590
|
-
nodeClaim: z4.string().optional()
|
|
4591
|
-
}),
|
|
4592
|
-
z4.object({ kind: z4.literal("pulling-image"), since }),
|
|
4593
|
-
z4.object({ kind: z4.literal("starting-container"), since }),
|
|
4594
|
-
z4.object({ kind: z4.literal("warming-daemon"), since }),
|
|
4595
|
-
z4.object({ kind: z4.literal("ready") }),
|
|
4596
|
-
z4.object({
|
|
4597
|
-
kind: z4.literal("failed"),
|
|
4598
|
-
reason: z4.enum([
|
|
4599
|
-
"image-pull-backoff",
|
|
4600
|
-
"crash-loop-backoff",
|
|
4601
|
-
"scheduling-timeout",
|
|
4602
|
-
"claim-never-created",
|
|
4603
|
-
"reconciler-error",
|
|
4604
|
-
"unknown"
|
|
4605
|
-
]),
|
|
4606
|
-
message: z4.string()
|
|
4607
|
-
})
|
|
4608
|
-
]
|
|
4609
|
-
);
|
|
4610
|
-
var tenantIdsSchema = z4.object({ orgId: id, userId: id });
|
|
4611
|
-
var repoIdentitySchema = z4.union([
|
|
4612
|
-
z4.object({ repositoryId: id }).strict(),
|
|
4613
|
-
z4.object({ connectionId: id, repo: id }).strict()
|
|
4614
|
-
]);
|
|
4615
|
-
var CREDENTIALS_PUSH_MAX = 2e3;
|
|
4616
|
-
var pushedCloneUrl = z4.url({ protocol: /^https?$/ }).max(CLONE_URL_MAX);
|
|
4617
|
-
var credentialsPushInputSchema = z4.object({
|
|
4618
|
-
cloneUrls: z4.array(
|
|
4619
|
-
z4.object({
|
|
4620
|
-
tenant: tenantIdsSchema,
|
|
4621
|
-
repo: repoIdentitySchema,
|
|
4622
|
-
cloneUrl: pushedCloneUrl,
|
|
4623
|
-
expiresAt
|
|
4624
|
-
})
|
|
4625
|
-
).max(CREDENTIALS_PUSH_MAX),
|
|
4626
|
-
/** Clone URLs for tenant pool allocations, keyed by the pool's tenant and repo. */
|
|
4627
|
-
poolCloneUrls: z4.array(
|
|
4628
|
-
z4.object({
|
|
4629
|
-
tenant: id,
|
|
4630
|
-
repoUrl: z4.string().min(1).max(CLONE_URL_MAX),
|
|
4631
|
-
cloneUrl: pushedCloneUrl,
|
|
4632
|
-
expiresAt
|
|
4633
|
-
})
|
|
4634
|
-
).max(CREDENTIALS_PUSH_MAX).default([]),
|
|
4635
|
-
orgFsConfigs: z4.array(
|
|
4636
|
-
z4.object({
|
|
4637
|
-
tenant: tenantIdsSchema,
|
|
4638
|
-
orgFsConfigJson: z4.string().min(1).max(ORG_FS_CONFIG_MAX),
|
|
4639
|
-
expiresAt
|
|
4640
|
-
})
|
|
4641
|
-
).max(CREDENTIALS_PUSH_MAX)
|
|
4642
|
-
});
|
|
4643
|
-
var credentialsPushOutputSchema = z4.object({
|
|
4644
|
-
stored: z4.number().int().nonnegative(),
|
|
4645
|
-
kept: z4.number().int().nonnegative()
|
|
4646
|
-
});
|
|
4647
|
-
var SANDBOX_LIST_MAX = 5e3;
|
|
4648
|
-
var SANDBOX_LIST_POOLS_MAX = 500;
|
|
4649
|
-
var SANDBOX_LIST_POOL_REPOS_MAX = 100;
|
|
4650
|
-
var listOutputSchema = z4.object({
|
|
4651
|
-
sandboxes: z4.array(
|
|
4652
|
-
z4.object({
|
|
4653
|
-
handle: id,
|
|
4654
|
-
tenant: tenantIdsSchema.nullable(),
|
|
4655
|
-
repos: z4.array(repoIdentitySchema).max(16),
|
|
4656
|
-
/** The sandbox mounts org-fs. */
|
|
4657
|
-
orgFs: z4.boolean(),
|
|
4658
|
-
/** When the org-fs config the host holds for its tenant expires. */
|
|
4659
|
-
orgFsConfigExpiresAt: expiresAt.nullable()
|
|
4660
|
-
})
|
|
4661
|
-
).max(SANDBOX_LIST_MAX),
|
|
4662
|
-
/** Defaulted so a host that predates pool listing still lists its sandboxes. */
|
|
4663
|
-
pools: z4.array(
|
|
4664
|
-
z4.object({
|
|
4665
|
-
name: id,
|
|
4666
|
-
tenant: id,
|
|
4667
|
-
image: SandboxImageSchema,
|
|
4668
|
-
repos: z4.array(
|
|
4669
|
-
z4.object({
|
|
4670
|
-
repoUrl: z4.string().min(1).max(CLONE_URL_MAX),
|
|
4671
|
-
branch: z4.string().min(1)
|
|
4672
|
-
})
|
|
4673
|
-
).max(SANDBOX_LIST_POOL_REPOS_MAX)
|
|
4674
|
-
})
|
|
4675
|
-
).max(SANDBOX_LIST_POOLS_MAX).default([])
|
|
4676
|
-
});
|
|
4677
4487
|
|
|
4678
|
-
//
|
|
4679
|
-
function
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4488
|
+
// ../../node_modules/.bun/freestyle@0.2.16/node_modules/freestyle/dist/runtime.js
|
|
4489
|
+
function isNodeRuntime() {
|
|
4490
|
+
return typeof process !== "undefined" && Boolean(process.versions?.node);
|
|
4491
|
+
}
|
|
4492
|
+
var globalFetch = (input, init) => globalThis.fetch(input, init);
|
|
4493
|
+
|
|
4494
|
+
// ../../node_modules/.bun/freestyle@0.2.16/node_modules/freestyle/dist/transport.js
|
|
4495
|
+
async function loadUndici() {
|
|
4496
|
+
if (!isNodeRuntime())
|
|
4497
|
+
return null;
|
|
4498
|
+
try {
|
|
4499
|
+
const specifier = "undici";
|
|
4500
|
+
return await import(
|
|
4501
|
+
/* @vite-ignore */
|
|
4502
|
+
specifier
|
|
4503
|
+
);
|
|
4504
|
+
} catch {
|
|
4505
|
+
return null;
|
|
4684
4506
|
}
|
|
4685
|
-
return parts.join(": ");
|
|
4686
4507
|
}
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
|
|
4508
|
+
var MAX_CONNECTIONS = 4;
|
|
4509
|
+
async function createHttp2Fetch() {
|
|
4510
|
+
const undici = await loadUndici();
|
|
4511
|
+
if (!undici)
|
|
4512
|
+
return null;
|
|
4513
|
+
const dispatcher = new undici.Agent({ allowH2: true, connections: MAX_CONNECTIONS });
|
|
4514
|
+
return ((input, init) => undici.fetch(input, { ...init, dispatcher }));
|
|
4515
|
+
}
|
|
4516
|
+
function isTls(input) {
|
|
4517
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
4518
|
+
return url.startsWith("https:");
|
|
4519
|
+
}
|
|
4520
|
+
function defaultFetch() {
|
|
4521
|
+
let pending;
|
|
4522
|
+
const resolve = () => {
|
|
4523
|
+
pending ??= createHttp2Fetch().then((h2) => h2 ?? globalFetch).catch(() => globalFetch);
|
|
4524
|
+
return pending;
|
|
4525
|
+
};
|
|
4526
|
+
return (input, init) => isTls(input) ? resolve().then((impl) => impl(input, init)) : globalFetch(input, init);
|
|
4527
|
+
}
|
|
4528
|
+
|
|
4529
|
+
// ../../node_modules/.bun/freestyle@0.2.16/node_modules/freestyle/dist/client.js
|
|
4530
|
+
var IDENTITY_TOKEN_HEADER = "x-freestyle-identity-access-token";
|
|
4531
|
+
var BACKGROUND_AFTER_SECS_HEADER = "x-freestyle-background-after-secs";
|
|
4532
|
+
var BACKGROUND_REQUEST_ID_HEADER = "x-freestyle-background-request-id";
|
|
4533
|
+
var BACKGROUND_AFTER_SECS = 5;
|
|
4534
|
+
var BACKGROUND_POLL_INTERVAL_MS = 2e3;
|
|
4535
|
+
var DEFAULT_BASE_URL = "https://api.freestyle.sh";
|
|
4536
|
+
function delay2(ms) {
|
|
4537
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4538
|
+
}
|
|
4539
|
+
function buildQuery(query) {
|
|
4540
|
+
if (!query)
|
|
4541
|
+
return "";
|
|
4542
|
+
const params = new URLSearchParams();
|
|
4543
|
+
for (const [key, value] of Object.entries(query)) {
|
|
4544
|
+
if (value === void 0 || value === null)
|
|
4545
|
+
continue;
|
|
4546
|
+
params.set(key, String(value));
|
|
4547
|
+
}
|
|
4548
|
+
const qs = params.toString();
|
|
4549
|
+
return qs ? `?${qs}` : "";
|
|
4550
|
+
}
|
|
4551
|
+
function segment(value) {
|
|
4552
|
+
return encodeURIComponent(value);
|
|
4553
|
+
}
|
|
4554
|
+
var FreestyleClient = class {
|
|
4555
|
+
baseUrl;
|
|
4556
|
+
authenticationHeaders;
|
|
4557
|
+
fetchImpl;
|
|
4558
|
+
/** Identity-token callers cannot poll `/v5/background-requests`, so their requests are never backgrounded. */
|
|
4559
|
+
backgroundEligible;
|
|
4560
|
+
constructor(options = {}) {
|
|
4561
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
4562
|
+
if (!options.fetch && !globalThis.fetch) {
|
|
4563
|
+
throw new Error("No fetch implementation available. Pass `fetch` in FreestyleOptions when running on a runtime without a global fetch.");
|
|
4564
|
+
}
|
|
4565
|
+
this.fetchImpl = options.fetch ?? defaultFetch();
|
|
4566
|
+
if ("identityAccessToken" in options && options.identityAccessToken) {
|
|
4567
|
+
this.authenticationHeaders = {
|
|
4568
|
+
[IDENTITY_TOKEN_HEADER]: options.identityAccessToken
|
|
4569
|
+
};
|
|
4570
|
+
this.backgroundEligible = false;
|
|
4571
|
+
return;
|
|
4572
|
+
}
|
|
4573
|
+
this.backgroundEligible = true;
|
|
4574
|
+
if ("stackAccessToken" in options && options.stackAccessToken) {
|
|
4575
|
+
this.authenticationHeaders = {
|
|
4576
|
+
Authorization: `Bearer ${options.stackAccessToken}`,
|
|
4577
|
+
"X-Freestyle-Team-Id": options.teamId
|
|
4578
|
+
};
|
|
4579
|
+
return;
|
|
4580
|
+
}
|
|
4581
|
+
const apiKey = options.apiKey ?? globalThis.process?.env?.FREESTYLE_API_KEY;
|
|
4582
|
+
if (!apiKey) {
|
|
4583
|
+
throw new Error("No Freestyle API key provided. Pass `apiKey` in FreestyleOptions or set FREESTYLE_API_KEY.");
|
|
4584
|
+
}
|
|
4585
|
+
this.authenticationHeaders = { Authorization: `Bearer ${apiKey}` };
|
|
4586
|
+
}
|
|
4587
|
+
/** Headers this client authenticates every request with, e.g. for a raw WebSocket handshake. */
|
|
4588
|
+
authHeaders() {
|
|
4589
|
+
return { ...this.authenticationHeaders };
|
|
4590
|
+
}
|
|
4591
|
+
/** Absolute URL for an API path, e.g. `/v5/vms/{id}` → `https://.../v5/vms/{id}`. */
|
|
4592
|
+
url(path, query) {
|
|
4593
|
+
return `${this.baseUrl}${path}${buildQuery(query)}`;
|
|
4594
|
+
}
|
|
4595
|
+
/**
|
|
4596
|
+
* Low-level escape hatch: an authenticated `fetch` against the API, relative
|
|
4597
|
+
* to `baseUrl`. Pass the whole path, `/v5` included — the same paths the
|
|
4598
|
+
* methods above use, and the same ones the API declares. It is also how the
|
|
4599
|
+
* client follows a server-supplied URL (a 202's `resultUrl`), and the only
|
|
4600
|
+
* way to reach the unversioned `/status` and `/openapi.json`.
|
|
4601
|
+
*/
|
|
4602
|
+
async fetch(path, init = {}) {
|
|
4603
|
+
const headers = new Headers(init.headers);
|
|
4604
|
+
for (const [name, value] of Object.entries(this.authHeaders())) {
|
|
4605
|
+
if (!headers.has(name))
|
|
4606
|
+
headers.set(name, value);
|
|
4607
|
+
}
|
|
4608
|
+
return this.fetchImpl(this.absoluteUrl(path), { ...init, headers });
|
|
4609
|
+
}
|
|
4610
|
+
/** A path or URL as this client's `fetch` resolves it: absolute URLs are
|
|
4611
|
+
* followed as given, everything else hangs off `baseUrl` with no version
|
|
4612
|
+
* segment added — the caller already supplied whatever version it wants. */
|
|
4613
|
+
absoluteUrl(pathOrUrl) {
|
|
4614
|
+
return /^https?:\/\//i.test(pathOrUrl) ? pathOrUrl : `${this.baseUrl}${pathOrUrl}`;
|
|
4615
|
+
}
|
|
4616
|
+
async request(method, path, { query, body, rawBody, signal } = {}) {
|
|
4617
|
+
const headers = { ...this.authHeaders() };
|
|
4618
|
+
if (this.backgroundEligible) {
|
|
4619
|
+
headers[BACKGROUND_AFTER_SECS_HEADER] = String(BACKGROUND_AFTER_SECS);
|
|
4620
|
+
}
|
|
4621
|
+
let payload;
|
|
4622
|
+
if (rawBody !== void 0) {
|
|
4623
|
+
headers["Content-Type"] = "application/octet-stream";
|
|
4624
|
+
payload = rawBody;
|
|
4625
|
+
} else if (body !== void 0) {
|
|
4626
|
+
headers["Content-Type"] = "application/json";
|
|
4627
|
+
payload = JSON.stringify(body);
|
|
4628
|
+
}
|
|
4629
|
+
const url = this.url(path, query);
|
|
4630
|
+
const response = await this.fetchImpl(url, {
|
|
4631
|
+
method,
|
|
4632
|
+
headers,
|
|
4633
|
+
body: payload,
|
|
4634
|
+
signal
|
|
4635
|
+
});
|
|
4636
|
+
if (this.backgroundEligible && response.status === 202) {
|
|
4637
|
+
try {
|
|
4638
|
+
return await this.pollBackgroundRequest(response, path);
|
|
4639
|
+
} catch (error) {
|
|
4640
|
+
if (method === "GET" && error instanceof FreestyleApiError && error.code === "BACKGROUND_RESULT_UNSTORABLE") {
|
|
4641
|
+
delete headers[BACKGROUND_AFTER_SECS_HEADER];
|
|
4642
|
+
const retry2 = await this.fetchImpl(url, { method, headers, body: payload });
|
|
4643
|
+
return this.finalize(retry2, path);
|
|
4644
|
+
}
|
|
4645
|
+
throw error;
|
|
4646
|
+
}
|
|
4647
|
+
}
|
|
4648
|
+
return this.finalize(response, path);
|
|
4649
|
+
}
|
|
4650
|
+
/**
|
|
4651
|
+
* Follow a 202 from a backgrounded request: poll its result URL until the
|
|
4652
|
+
* stored response — success or error, identical to what the inline call
|
|
4653
|
+
* would have produced — is ready.
|
|
4654
|
+
*/
|
|
4655
|
+
async pollBackgroundRequest(accepted, path) {
|
|
4656
|
+
const acceptedBody = await accepted.json().catch(() => ({}));
|
|
4657
|
+
const requestId = accepted.headers.get(BACKGROUND_REQUEST_ID_HEADER) ?? acceptedBody.requestId;
|
|
4658
|
+
const resultPath = acceptedBody.resultUrl ?? (requestId ? `/v5/background-requests/${segment(requestId)}` : void 0);
|
|
4659
|
+
if (!resultPath) {
|
|
4660
|
+
throw new FreestyleApiError(accepted.status, { code: "BACKGROUND_PROTOCOL_ERROR", message: "202 Accepted carried no background request id" }, path);
|
|
4661
|
+
}
|
|
4662
|
+
let consecutiveFailures = 0;
|
|
4663
|
+
for (; ; ) {
|
|
4664
|
+
await delay2(BACKGROUND_POLL_INTERVAL_MS);
|
|
4665
|
+
let response;
|
|
4666
|
+
try {
|
|
4667
|
+
response = await this.fetch(resultPath, { method: "GET" });
|
|
4668
|
+
consecutiveFailures = 0;
|
|
4669
|
+
} catch (error) {
|
|
4670
|
+
if (++consecutiveFailures >= 5)
|
|
4671
|
+
throw error;
|
|
4672
|
+
continue;
|
|
4673
|
+
}
|
|
4674
|
+
if (response.status === 202)
|
|
4675
|
+
continue;
|
|
4676
|
+
return this.finalize(response, path);
|
|
4677
|
+
}
|
|
4678
|
+
}
|
|
4679
|
+
/** Turn a terminal API response into the call's value, or throw its error. */
|
|
4680
|
+
async finalize(response, path) {
|
|
4681
|
+
if (!response.ok) {
|
|
4682
|
+
let errorBody;
|
|
4683
|
+
try {
|
|
4684
|
+
errorBody = await response.json();
|
|
4685
|
+
} catch {
|
|
4686
|
+
errorBody = { code: "UNKNOWN_ERROR", message: await response.text().catch(() => response.statusText) };
|
|
4687
|
+
}
|
|
4688
|
+
throw new FreestyleApiError(response.status, errorBody, path);
|
|
4689
|
+
}
|
|
4690
|
+
if (response.status === 204)
|
|
4691
|
+
return void 0;
|
|
4692
|
+
const text = await response.text();
|
|
4693
|
+
if (!text)
|
|
4694
|
+
return void 0;
|
|
4695
|
+
return JSON.parse(text);
|
|
4696
|
+
}
|
|
4697
|
+
get(path, query) {
|
|
4698
|
+
return this.request("GET", path, { query });
|
|
4699
|
+
}
|
|
4700
|
+
post(path, body) {
|
|
4701
|
+
return this.request("POST", path, { body: body ?? {} });
|
|
4702
|
+
}
|
|
4703
|
+
patch(path, body) {
|
|
4704
|
+
return this.request("PATCH", path, { body: body ?? {} });
|
|
4705
|
+
}
|
|
4706
|
+
put(path, body) {
|
|
4707
|
+
return this.request("PUT", path, { body: body ?? {} });
|
|
4708
|
+
}
|
|
4709
|
+
putEmpty(path) {
|
|
4710
|
+
return this.request("PUT", path, {});
|
|
4711
|
+
}
|
|
4712
|
+
/** PUT raw bytes as `application/octet-stream`: a whole file, or one chunk of one. */
|
|
4713
|
+
putBytes(path, rawBody, query, signal) {
|
|
4714
|
+
return this.request("PUT", path, { query, rawBody, signal });
|
|
4715
|
+
}
|
|
4716
|
+
/**
|
|
4717
|
+
* GET a response whose body is bytes rather than JSON, returned unread so the
|
|
4718
|
+
* caller can stream it. Errors still surface as {@link FreestyleApiError}.
|
|
4719
|
+
*/
|
|
4720
|
+
async getBytes(path, query, init = {}) {
|
|
4721
|
+
const headers = { ...this.authHeaders() };
|
|
4722
|
+
if (init.range)
|
|
4723
|
+
headers["Range"] = init.range;
|
|
4724
|
+
const response = await this.fetchImpl(this.url(path, query), {
|
|
4725
|
+
method: "GET",
|
|
4726
|
+
headers,
|
|
4727
|
+
signal: init.signal
|
|
4728
|
+
});
|
|
4729
|
+
if (!response.ok) {
|
|
4730
|
+
let errorBody;
|
|
4731
|
+
try {
|
|
4732
|
+
errorBody = await response.json();
|
|
4733
|
+
} catch {
|
|
4734
|
+
errorBody = {
|
|
4735
|
+
code: "UNKNOWN_ERROR",
|
|
4736
|
+
message: await response.text().catch(() => response.statusText)
|
|
4737
|
+
};
|
|
4738
|
+
}
|
|
4739
|
+
throw new FreestyleApiError(response.status, errorBody, path);
|
|
4740
|
+
}
|
|
4741
|
+
return response;
|
|
4742
|
+
}
|
|
4743
|
+
delete(path, query) {
|
|
4744
|
+
return this.request("DELETE", path, { query });
|
|
4745
|
+
}
|
|
4746
|
+
};
|
|
4747
|
+
|
|
4748
|
+
// ../../node_modules/.bun/freestyle@0.2.16/node_modules/freestyle/dist/domains.js
|
|
4749
|
+
var DomainsNamespace = class {
|
|
4750
|
+
client;
|
|
4751
|
+
verifications;
|
|
4752
|
+
certificates;
|
|
4753
|
+
/** The branding on the 404 page for hostnames under your domains that nothing serves. */
|
|
4754
|
+
placeholder;
|
|
4755
|
+
constructor(client) {
|
|
4756
|
+
this.client = client;
|
|
4757
|
+
this.verifications = new DomainVerificationsNamespace(client);
|
|
4758
|
+
this.certificates = new DomainCertificatesNamespace(client);
|
|
4759
|
+
this.placeholder = new DomainPlaceholderNamespace(client);
|
|
4760
|
+
}
|
|
4761
|
+
/**
|
|
4762
|
+
* List the domains you hold: those you verified, and any Freestyle
|
|
4763
|
+
* subdomain you have taken by publishing it.
|
|
4764
|
+
*/
|
|
4765
|
+
async list() {
|
|
4766
|
+
const { domains } = await this.client.get("/v5/domains");
|
|
4767
|
+
return domains;
|
|
4768
|
+
}
|
|
4769
|
+
};
|
|
4770
|
+
var DomainVerificationsNamespace = class {
|
|
4771
|
+
client;
|
|
4772
|
+
constructor(client) {
|
|
4773
|
+
this.client = client;
|
|
4774
|
+
}
|
|
4775
|
+
/**
|
|
4776
|
+
* Issue a challenge. Publish the returned `verificationCode` as a TXT
|
|
4777
|
+
* record at `recordName`, then `complete()` it.
|
|
4778
|
+
*/
|
|
4779
|
+
create(domain) {
|
|
4780
|
+
return this.client.post("/v5/verifications", { domain });
|
|
4781
|
+
}
|
|
4782
|
+
async list(options = {}) {
|
|
4783
|
+
const { verifications } = await this.client.get("/v5/verifications", options);
|
|
4784
|
+
return verifications;
|
|
4785
|
+
}
|
|
4786
|
+
/** Fetch a verification challenge by its id or its domain. */
|
|
4787
|
+
get(domainOrId) {
|
|
4788
|
+
return this.client.get(`/v5/verifications/${segment(domainOrId)}`);
|
|
4789
|
+
}
|
|
4790
|
+
/**
|
|
4791
|
+
* Check DNS and record ownership. Addressed by id, only that challenge's
|
|
4792
|
+
* own code counts; use this when several people are verifying the same
|
|
4793
|
+
* domain.
|
|
4794
|
+
*/
|
|
4795
|
+
complete(domainOrId) {
|
|
4796
|
+
return this.client.putEmpty(`/v5/verifications/${segment(domainOrId)}`);
|
|
4797
|
+
}
|
|
4798
|
+
/**
|
|
4799
|
+
* Withdraw outstanding challenges; by domain this drops all of them. A
|
|
4800
|
+
* challenge that already verified is a permanent record and cannot be
|
|
4801
|
+
* deleted.
|
|
4802
|
+
*/
|
|
4803
|
+
delete(domainOrId) {
|
|
4804
|
+
return this.client.delete(`/v5/verifications/${segment(domainOrId)}`);
|
|
4805
|
+
}
|
|
4806
|
+
};
|
|
4807
|
+
var DomainCertificatesNamespace = class {
|
|
4808
|
+
client;
|
|
4809
|
+
constructor(client) {
|
|
4810
|
+
this.client = client;
|
|
4811
|
+
}
|
|
4812
|
+
async list() {
|
|
4813
|
+
const { certificates } = await this.client.get("/v5/certificates");
|
|
4814
|
+
return certificates;
|
|
4815
|
+
}
|
|
4816
|
+
/**
|
|
4817
|
+
* Request an ongoing `*.domain` certificate. Requires
|
|
4818
|
+
* `_acme-challenge.<domain>` to be NS-delegated to Freestyle's
|
|
4819
|
+
* nameservers, since wildcards can only be proven over DNS.
|
|
4820
|
+
*/
|
|
4821
|
+
createWildcard(domain) {
|
|
4822
|
+
return this.client.post("/v5/certificates/wildcard", { domain });
|
|
4823
|
+
}
|
|
4824
|
+
};
|
|
4825
|
+
var DomainPlaceholderNamespace = class {
|
|
4826
|
+
client;
|
|
4827
|
+
constructor(client) {
|
|
4828
|
+
this.client = client;
|
|
4829
|
+
}
|
|
4830
|
+
/** Your placeholder branding, or `null` when pages carry the Freestyle default. */
|
|
4831
|
+
async get() {
|
|
4832
|
+
try {
|
|
4833
|
+
return await this.client.get("/v5/domains/placeholder");
|
|
4834
|
+
} catch (error) {
|
|
4835
|
+
if (error instanceof FreestyleApiError && error.status === 404)
|
|
4836
|
+
return null;
|
|
4837
|
+
throw error;
|
|
4838
|
+
}
|
|
4839
|
+
}
|
|
4840
|
+
/**
|
|
4841
|
+
* Set the branding for every placeholder page under your verified domains,
|
|
4842
|
+
* including domains you verify later. Replaces any earlier setting.
|
|
4843
|
+
*/
|
|
4844
|
+
set(options) {
|
|
4845
|
+
return this.client.put("/v5/domains/placeholder", options);
|
|
4846
|
+
}
|
|
4847
|
+
/** Remove your branding; pages go back to the Freestyle default. */
|
|
4848
|
+
delete() {
|
|
4849
|
+
return this.client.delete("/v5/domains/placeholder");
|
|
4850
|
+
}
|
|
4851
|
+
};
|
|
4852
|
+
|
|
4853
|
+
// ../../node_modules/.bun/freestyle@0.2.16/node_modules/freestyle/dist/firewall.js
|
|
4854
|
+
var FirewallRuleValidationError = class _FirewallRuleValidationError extends Error {
|
|
4855
|
+
/** The field the complaint is about, in dotted form. */
|
|
4856
|
+
path;
|
|
4857
|
+
constructor(path, message) {
|
|
4858
|
+
super(message);
|
|
4859
|
+
this.name = "FirewallRuleValidationError";
|
|
4860
|
+
this.path = path;
|
|
4861
|
+
Object.setPrototypeOf(this, _FirewallRuleValidationError.prototype);
|
|
4862
|
+
}
|
|
4863
|
+
};
|
|
4864
|
+
var ENDPOINT_FIELDS = [
|
|
4865
|
+
"vmId",
|
|
4866
|
+
"vpcId",
|
|
4867
|
+
"tunnelId",
|
|
4868
|
+
"cidr",
|
|
4869
|
+
"public",
|
|
4870
|
+
"port",
|
|
4871
|
+
"protocol"
|
|
4872
|
+
];
|
|
4873
|
+
var IDENTITY_FIELDS = ["vmId", "vpcId", "tunnelId", "cidr", "public"];
|
|
4874
|
+
var PROTOCOLS = ["tcp", "udp", "icmp"];
|
|
4875
|
+
var PORTED_PROTOCOLS = ["tcp", "udp"];
|
|
4876
|
+
var MAX_DESCRIPTION_LENGTH = 1024;
|
|
4877
|
+
function fail(path, message) {
|
|
4878
|
+
throw new FirewallRuleValidationError(path, message);
|
|
4879
|
+
}
|
|
4880
|
+
function hasIdentity(endpoint) {
|
|
4881
|
+
return IDENTITY_FIELDS.some((field) => endpoint[field] !== void 0);
|
|
4882
|
+
}
|
|
4883
|
+
function isPlainObject(value) {
|
|
4884
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4885
|
+
}
|
|
4886
|
+
function validateCidr(path, cidr) {
|
|
4887
|
+
const slash = cidr.lastIndexOf("/");
|
|
4888
|
+
if (slash < 0) {
|
|
4889
|
+
fail(path, `${path} \`${cidr}\` is not a CIDR: expected address/prefix, e.g. 203.0.113.0/24 or 2001:db8::/32`);
|
|
4890
|
+
}
|
|
4891
|
+
const address = cidr.slice(0, slash);
|
|
4892
|
+
const prefixText = cidr.slice(slash + 1);
|
|
4893
|
+
if (!/^\d+$/.test(prefixText)) {
|
|
4894
|
+
fail(path, `${path} \`${cidr}\`: prefix length must be a number`);
|
|
4895
|
+
}
|
|
4896
|
+
const prefix = Number(prefixText);
|
|
4897
|
+
const isIpv6 = address.includes(":");
|
|
4898
|
+
const max = isIpv6 ? 128 : 32;
|
|
4899
|
+
if (prefix > max) {
|
|
4900
|
+
fail(path, `${path} \`${cidr}\`: prefix length must be 0\u2013${max}`);
|
|
4901
|
+
}
|
|
4902
|
+
if (!isIpv6 && !/^\d{1,3}(\.\d{1,3}){3}$/.test(address)) {
|
|
4903
|
+
fail(path, `${path} \`${cidr}\`: \`${address}\` is not an IP address`);
|
|
4904
|
+
}
|
|
4905
|
+
}
|
|
4906
|
+
function validateFirewallEndpoint(endpoint, path) {
|
|
4907
|
+
if (!isPlainObject(endpoint)) {
|
|
4908
|
+
fail(path, `${path} must be an object`);
|
|
4909
|
+
}
|
|
4910
|
+
for (const field of Object.keys(endpoint)) {
|
|
4911
|
+
if (!ENDPOINT_FIELDS.includes(field)) {
|
|
4912
|
+
fail(`${path}.${field}`, `${path} has unknown field \`${field}\`. Expected one of: ${ENDPOINT_FIELDS.join(", ")}`);
|
|
4913
|
+
}
|
|
4914
|
+
}
|
|
4915
|
+
const matcher = endpoint;
|
|
4916
|
+
for (const field of ["vmId", "vpcId", "tunnelId", "cidr"]) {
|
|
4917
|
+
const value = matcher[field];
|
|
4918
|
+
if (value !== void 0 && (typeof value !== "string" || value.length === 0)) {
|
|
4919
|
+
fail(`${path}.${field}`, `${path}.${field} must be a non-empty string`);
|
|
4920
|
+
}
|
|
4921
|
+
}
|
|
4922
|
+
if (matcher.public !== void 0 && matcher.public !== true) {
|
|
4923
|
+
fail(`${path}.public`, `${path}.public must be true when present: it declares the public Internet. Omit the field to match something else`);
|
|
4924
|
+
}
|
|
4925
|
+
if (matcher.public === true) {
|
|
4926
|
+
if (matcher.vmId !== void 0) {
|
|
4927
|
+
fail(`${path}.public`, `${path} cannot combine \`public: true\` with \`vmId\`: that would mean the VM only when reached over its public address, a distinction this API does not make yet. Use \`vmId\` alone to match the VM on any path, or \`public: true\` alone to match public addresses`);
|
|
4928
|
+
}
|
|
4929
|
+
if (matcher.vpcId !== void 0) {
|
|
4930
|
+
fail(`${path}.public`, `${path} cannot combine \`public: true\` with \`vpcId\`: that would mean the network's members only when reached over their public addresses, a distinction this API does not make yet. Use \`vpcId\` alone or \`public: true\` alone`);
|
|
4931
|
+
}
|
|
4932
|
+
if (matcher.tunnelId !== void 0) {
|
|
4933
|
+
fail(`${path}.public`, `${path} cannot combine \`public: true\` with \`tunnelId\`: traffic through a tunnel arrives over the tunnel, not over the public Internet. Use \`tunnelId\` alone`);
|
|
4934
|
+
}
|
|
4935
|
+
}
|
|
4936
|
+
if (matcher.cidr !== void 0) {
|
|
4937
|
+
validateCidr(`${path}.cidr`, matcher.cidr);
|
|
4938
|
+
}
|
|
4939
|
+
if (matcher.protocol !== void 0 && !PROTOCOLS.includes(matcher.protocol)) {
|
|
4940
|
+
fail(`${path}.protocol`, `${path}.protocol must be one of: ${PROTOCOLS.join(", ")} (got \`${String(matcher.protocol)}\`)`);
|
|
4941
|
+
}
|
|
4942
|
+
if (matcher.port !== void 0) {
|
|
4943
|
+
if (!Number.isInteger(matcher.port) || matcher.port < 1 || matcher.port > 65535) {
|
|
4944
|
+
fail(`${path}.port`, `${path}.port must be an integer between 1 and 65535`);
|
|
4945
|
+
}
|
|
4946
|
+
if (matcher.protocol === void 0) {
|
|
4947
|
+
fail(`${path}.protocol`, `${path}.port ${matcher.port} requires ${path}.protocol: set it to "tcp" or "udp" so the rule opens one transport rather than both`);
|
|
4948
|
+
}
|
|
4949
|
+
if (!PORTED_PROTOCOLS.includes(matcher.protocol)) {
|
|
4950
|
+
fail(`${path}.port`, `${path}.protocol "${matcher.protocol}" has no ports; remove ${path}.port ${matcher.port}`);
|
|
4951
|
+
}
|
|
4952
|
+
}
|
|
4953
|
+
}
|
|
4954
|
+
var RULE_FIELDS = ["action", "source", "destination", "description"];
|
|
4955
|
+
function validateRuleShape(rule, prefix) {
|
|
4956
|
+
if (!isPlainObject(rule)) {
|
|
4957
|
+
fail(prefix || "rule", `${prefix || "rule"} must be an object`);
|
|
4958
|
+
}
|
|
4959
|
+
for (const field of Object.keys(rule)) {
|
|
4960
|
+
if (!RULE_FIELDS.includes(field)) {
|
|
4961
|
+
fail(`${prefix}${field}`, `Unknown field \`${field}\`. Expected one of: ${RULE_FIELDS.join(", ")}`);
|
|
4962
|
+
}
|
|
4963
|
+
}
|
|
4964
|
+
const typed = rule;
|
|
4965
|
+
if (typed.action === void 0) {
|
|
4966
|
+
fail(`${prefix}action`, `${prefix}action is required. Write \`action: "allow"\` \u2014 every rule states what it does rather than defaulting to it`);
|
|
4967
|
+
}
|
|
4968
|
+
if (typed.action !== "allow") {
|
|
4969
|
+
fail(`${prefix}action`, `${prefix}action must be "allow" (got \`${String(typed.action)}\`). Only allow rules exist today`);
|
|
4970
|
+
}
|
|
4971
|
+
if (typed.description !== void 0) {
|
|
4972
|
+
if (typeof typed.description !== "string") {
|
|
4973
|
+
fail(`${prefix}description`, "description must be a string");
|
|
4974
|
+
}
|
|
4975
|
+
if (typed.description.length > MAX_DESCRIPTION_LENGTH) {
|
|
4976
|
+
fail(`${prefix}description`, `description is too long (${typed.description.length} characters, maximum ${MAX_DESCRIPTION_LENGTH})`);
|
|
4977
|
+
}
|
|
4978
|
+
}
|
|
4979
|
+
if (typed.source === void 0)
|
|
4980
|
+
fail(`${prefix}source`, `${prefix}source is required`);
|
|
4981
|
+
if (typed.destination === void 0) {
|
|
4982
|
+
fail(`${prefix}destination`, `${prefix}destination is required`);
|
|
4983
|
+
}
|
|
4984
|
+
validateFirewallEndpoint(typed.source, `${prefix}source`);
|
|
4985
|
+
validateFirewallEndpoint(typed.destination, `${prefix}destination`);
|
|
4986
|
+
return typed;
|
|
4987
|
+
}
|
|
4988
|
+
function validateFirewallRule(rule) {
|
|
4989
|
+
const typed = validateRuleShape(rule, "");
|
|
4990
|
+
for (const side of ["source", "destination"]) {
|
|
4991
|
+
const endpoint = typed[side];
|
|
4992
|
+
if (hasIdentity(endpoint))
|
|
4993
|
+
continue;
|
|
4994
|
+
const narrowing = [
|
|
4995
|
+
endpoint.port !== void 0 ? `port ${endpoint.port}` : void 0,
|
|
4996
|
+
endpoint.protocol !== void 0 ? `protocol ${endpoint.protocol}` : void 0
|
|
4997
|
+
].filter(Boolean).join(", ");
|
|
4998
|
+
fail(side, narrowing ? `${side} only narrows traffic (${narrowing}) without saying what it is. Add \`vmId\`, \`vpcId\`, \`tunnelId\`, \`cidr\`, or \`public: true\`. Leaving it out to mean "this VM" only works inside vms.create` : `${side} is empty. A matcher must say what the traffic is: \`vmId\`, \`vpcId\`, \`tunnelId\`, \`cidr\`, or \`public: true\` for the public Internet`);
|
|
4999
|
+
}
|
|
5000
|
+
return typed;
|
|
5001
|
+
}
|
|
5002
|
+
function validateFirewallSpec(spec, subject = "VM") {
|
|
5003
|
+
if (spec === void 0 || spec === null) {
|
|
5004
|
+
fail("firewall", `firewall is required on a ${subject} create. A ${subject} gets nothing implicitly, so state what it may reach \u2014 \`{ rules: [] }\` if the answer is nothing`);
|
|
5005
|
+
}
|
|
5006
|
+
if (!isPlainObject(spec)) {
|
|
5007
|
+
fail("firewall", "firewall must be an object with a `rules` array");
|
|
5008
|
+
}
|
|
5009
|
+
for (const field of Object.keys(spec)) {
|
|
5010
|
+
if (field !== "rules") {
|
|
5011
|
+
fail(`firewall.${field}`, `firewall has unknown field \`${field}\`. Expected: rules`);
|
|
5012
|
+
}
|
|
5013
|
+
}
|
|
5014
|
+
const rules = spec.rules;
|
|
5015
|
+
if (!Array.isArray(rules)) {
|
|
5016
|
+
fail("firewall.rules", "firewall.rules must be an array");
|
|
5017
|
+
}
|
|
5018
|
+
rules.forEach((rule, index) => {
|
|
5019
|
+
const prefix = `firewall.rules[${index}].`;
|
|
5020
|
+
const typed = validateRuleShape(rule, prefix);
|
|
5021
|
+
const sourceNames = hasIdentity(typed.source);
|
|
5022
|
+
const destinationNames = hasIdentity(typed.destination);
|
|
5023
|
+
if (!sourceNames && !destinationNames && subject !== "network") {
|
|
5024
|
+
fail(`firewall.rules[${index}]`, `firewall.rules[${index}]: both source and destination refer to the ${subject} being created, which allows nothing. Give one of them a \`vmId\`, \`vpcId\`, \`tunnelId\`, \`cidr\`, or \`public: true\``);
|
|
5025
|
+
}
|
|
5026
|
+
if (sourceNames && destinationNames) {
|
|
5027
|
+
fail(`firewall.rules[${index}]`, `firewall.rules[${index}]: neither end refers to the ${subject} being created. Leave one endpoint's identity unset so it means this ${subject}, or create the rule with firewall.rules.create instead`);
|
|
5028
|
+
}
|
|
5029
|
+
});
|
|
5030
|
+
return spec;
|
|
5031
|
+
}
|
|
5032
|
+
function rulePath(ruleId) {
|
|
5033
|
+
return `/v5/firewall/rules/${segment(ruleId)}`;
|
|
5034
|
+
}
|
|
5035
|
+
var FirewallRulesNamespace = class {
|
|
5036
|
+
client;
|
|
5037
|
+
constructor(client) {
|
|
5038
|
+
this.client = client;
|
|
5039
|
+
}
|
|
5040
|
+
/**
|
|
5041
|
+
* Allow traffic from one place to another.
|
|
5042
|
+
*
|
|
5043
|
+
* @example
|
|
5044
|
+
* // Public Internet -> a VM, on HTTPS.
|
|
5045
|
+
* await freestyle.firewall.rules.create({
|
|
5046
|
+
* action: "allow",
|
|
5047
|
+
* source: { public: true },
|
|
5048
|
+
* destination: { vmId: "vm-123456", port: 443, protocol: "tcp" },
|
|
5049
|
+
* });
|
|
5050
|
+
*
|
|
5051
|
+
* @example
|
|
5052
|
+
* // One private network -> another, on Postgres.
|
|
5053
|
+
* await freestyle.firewall.rules.create({
|
|
5054
|
+
* action: "allow",
|
|
5055
|
+
* source: { vpcId: "vpc-frontend" },
|
|
5056
|
+
* destination: { vpcId: "vpc-backend", port: 5432, protocol: "tcp" },
|
|
5057
|
+
* });
|
|
5058
|
+
*
|
|
5059
|
+
* @throws {FirewallRuleValidationError} before any request, when the rule
|
|
5060
|
+
* cannot be well formed.
|
|
5061
|
+
*/
|
|
5062
|
+
// `async` so a validation failure arrives as a rejected promise like every
|
|
5063
|
+
// other error from this client, instead of a synchronous throw that a
|
|
5064
|
+
// `.catch()` chain would sail straight past.
|
|
5065
|
+
async create(options) {
|
|
5066
|
+
const validated = validateFirewallRule(options);
|
|
5067
|
+
return this.client.post("/v5/firewall/rules", validated);
|
|
5068
|
+
}
|
|
5069
|
+
/**
|
|
5070
|
+
* List your rules, newest first. Pass `vmId` for the rules that apply to one
|
|
5071
|
+
* VM — those naming it, plus those naming a private network it is on.
|
|
5072
|
+
*/
|
|
5073
|
+
list(options = {}) {
|
|
5074
|
+
return this.client.get("/v5/firewall/rules", options);
|
|
5075
|
+
}
|
|
5076
|
+
get(ruleId) {
|
|
5077
|
+
return this.client.get(rulePath(ruleId));
|
|
5078
|
+
}
|
|
5079
|
+
/** Delete a rule. The resources it named are untouched. */
|
|
5080
|
+
delete(ruleId) {
|
|
5081
|
+
return this.client.delete(rulePath(ruleId));
|
|
5082
|
+
}
|
|
5083
|
+
};
|
|
5084
|
+
var FirewallNamespace = class {
|
|
5085
|
+
client;
|
|
5086
|
+
rules;
|
|
5087
|
+
constructor(client) {
|
|
5088
|
+
this.client = client;
|
|
5089
|
+
this.rules = new FirewallRulesNamespace(client);
|
|
5090
|
+
}
|
|
5091
|
+
/**
|
|
5092
|
+
* Ask whether a connection would be allowed, without changing anything.
|
|
5093
|
+
*
|
|
5094
|
+
* Useful for checking a rule set before you rely on it — and for confirming
|
|
5095
|
+
* what you already believe, e.g. that a mapped domain reaches a VM with no
|
|
5096
|
+
* rules at all.
|
|
5097
|
+
*
|
|
5098
|
+
* @example
|
|
5099
|
+
* // A mapped domain: allowed whatever your rules say.
|
|
5100
|
+
* await freestyle.firewall.evaluate({
|
|
5101
|
+
* source: { platform: true },
|
|
5102
|
+
* destination: { vmId: "vm-123456", port: 3000 },
|
|
5103
|
+
* protocol: "tcp",
|
|
5104
|
+
* });
|
|
5105
|
+
* // => { outcome: "allowedByPlatform", side: "source" }
|
|
5106
|
+
*/
|
|
5107
|
+
evaluate(traffic) {
|
|
5108
|
+
return this.client.post("/v5/firewall/evaluate", traffic);
|
|
5109
|
+
}
|
|
5110
|
+
};
|
|
5111
|
+
|
|
5112
|
+
// ../../node_modules/.bun/freestyle@0.2.16/node_modules/freestyle/dist/identities.js
|
|
5113
|
+
function identityPath(id2) {
|
|
5114
|
+
return `/v5/identities/${segment(id2)}`;
|
|
5115
|
+
}
|
|
5116
|
+
var IdentityTokensNamespace = class {
|
|
5117
|
+
client;
|
|
5118
|
+
basePath;
|
|
5119
|
+
constructor(client, basePath) {
|
|
5120
|
+
this.client = client;
|
|
5121
|
+
this.basePath = basePath;
|
|
5122
|
+
}
|
|
5123
|
+
/** Mint an access token for the identity. */
|
|
5124
|
+
create() {
|
|
5125
|
+
return this.client.post(`${this.basePath}/tokens`);
|
|
5126
|
+
}
|
|
5127
|
+
list() {
|
|
5128
|
+
return this.client.get(`${this.basePath}/tokens`);
|
|
5129
|
+
}
|
|
5130
|
+
revoke(tokenId) {
|
|
5131
|
+
return this.client.delete(`${this.basePath}/tokens/${segment(tokenId)}`);
|
|
5132
|
+
}
|
|
5133
|
+
};
|
|
5134
|
+
var IdentityVmPermissionsNamespace = class {
|
|
5135
|
+
client;
|
|
5136
|
+
basePath;
|
|
5137
|
+
constructor(client, basePath) {
|
|
5138
|
+
this.client = client;
|
|
5139
|
+
this.basePath = basePath;
|
|
5140
|
+
}
|
|
5141
|
+
/** List the VMs this identity has been granted access to. */
|
|
5142
|
+
list() {
|
|
5143
|
+
return this.client.get(`${this.basePath}/permissions/vm`);
|
|
5144
|
+
}
|
|
5145
|
+
/** Grant this identity access to a VM. */
|
|
5146
|
+
grant(options) {
|
|
5147
|
+
return this.client.post(`${this.basePath}/permissions/vm`, options);
|
|
5148
|
+
}
|
|
5149
|
+
/** Fetch this identity's grant on a VM. */
|
|
5150
|
+
get(vmId) {
|
|
5151
|
+
return this.client.get(`${this.basePath}/permissions/vm/${segment(vmId)}`);
|
|
5152
|
+
}
|
|
5153
|
+
/** Change which Linux users this identity may act as on a VM. */
|
|
5154
|
+
update(vmId, allowedLinuxUsers) {
|
|
5155
|
+
return this.client.patch(`${this.basePath}/permissions/vm/${segment(vmId)}`, {
|
|
5156
|
+
allowedLinuxUsers
|
|
5157
|
+
});
|
|
5158
|
+
}
|
|
5159
|
+
/** Revoke this identity's access to a VM. */
|
|
5160
|
+
revoke(vmId) {
|
|
5161
|
+
return this.client.delete(`${this.basePath}/permissions/vm/${segment(vmId)}`);
|
|
5162
|
+
}
|
|
5163
|
+
};
|
|
5164
|
+
var Identity = class {
|
|
5165
|
+
id;
|
|
5166
|
+
tokens;
|
|
5167
|
+
permissions;
|
|
5168
|
+
client;
|
|
5169
|
+
basePath;
|
|
5170
|
+
constructor(client, id2) {
|
|
5171
|
+
this.client = client;
|
|
5172
|
+
this.id = id2;
|
|
5173
|
+
this.basePath = identityPath(id2);
|
|
5174
|
+
this.tokens = new IdentityTokensNamespace(client, this.basePath);
|
|
5175
|
+
this.permissions = { vm: new IdentityVmPermissionsNamespace(client, this.basePath) };
|
|
5176
|
+
}
|
|
5177
|
+
data() {
|
|
5178
|
+
return this.client.get(this.basePath);
|
|
5179
|
+
}
|
|
5180
|
+
/** Delete the identity, along with its tokens and grants. Managed identities cannot be deleted. */
|
|
5181
|
+
delete() {
|
|
5182
|
+
return this.client.delete(this.basePath);
|
|
5183
|
+
}
|
|
5184
|
+
};
|
|
5185
|
+
var IdentitiesNamespace = class {
|
|
5186
|
+
client;
|
|
5187
|
+
constructor(client) {
|
|
5188
|
+
this.client = client;
|
|
5189
|
+
}
|
|
5190
|
+
/** Create an identity. */
|
|
5191
|
+
async create(options = {}) {
|
|
5192
|
+
const data = await this.client.post("/v5/identities", options);
|
|
5193
|
+
return { identity: new Identity(this.client, data.id), identityId: data.id, data };
|
|
5194
|
+
}
|
|
5195
|
+
list(options = {}) {
|
|
5196
|
+
return this.client.get("/v5/identities", options);
|
|
5197
|
+
}
|
|
5198
|
+
get(identityId) {
|
|
5199
|
+
return this.client.get(identityPath(identityId));
|
|
5200
|
+
}
|
|
5201
|
+
/** A handle to an identity, without a network call. */
|
|
5202
|
+
ref(identityId) {
|
|
5203
|
+
return new Identity(this.client, identityId);
|
|
5204
|
+
}
|
|
5205
|
+
delete(identityId) {
|
|
5206
|
+
return this.client.delete(identityPath(identityId));
|
|
5207
|
+
}
|
|
5208
|
+
};
|
|
5209
|
+
|
|
5210
|
+
// ../../node_modules/.bun/freestyle@0.2.16/node_modules/freestyle/dist/tls.js
|
|
5211
|
+
var TlsRuleValidationError = class _TlsRuleValidationError extends Error {
|
|
5212
|
+
/** The field the complaint is about, in dotted form. */
|
|
5213
|
+
path;
|
|
5214
|
+
constructor(path, message) {
|
|
5215
|
+
super(message);
|
|
5216
|
+
this.name = "TlsRuleValidationError";
|
|
5217
|
+
this.path = path;
|
|
5218
|
+
Object.setPrototypeOf(this, _TlsRuleValidationError.prototype);
|
|
5219
|
+
}
|
|
5220
|
+
};
|
|
5221
|
+
var TlsForwardAuthValidationError = class _TlsForwardAuthValidationError extends Error {
|
|
5222
|
+
/** The field the complaint is about, in dotted form. */
|
|
5223
|
+
path;
|
|
5224
|
+
constructor(path, message) {
|
|
5225
|
+
super(message);
|
|
5226
|
+
this.name = "TlsForwardAuthValidationError";
|
|
5227
|
+
this.path = path;
|
|
5228
|
+
Object.setPrototypeOf(this, _TlsForwardAuthValidationError.prototype);
|
|
5229
|
+
}
|
|
5230
|
+
};
|
|
5231
|
+
var ENDPOINT_FIELDS2 = ["vmId", "vpcId", "public", "host", "port"];
|
|
5232
|
+
var IDENTITY_FIELDS2 = ["vmId", "vpcId", "public", "host"];
|
|
5233
|
+
var PROTOCOLS2 = [
|
|
5234
|
+
"tcp",
|
|
5235
|
+
"http",
|
|
5236
|
+
"postgres",
|
|
5237
|
+
"minecraft",
|
|
5238
|
+
"smtp",
|
|
5239
|
+
"imap",
|
|
5240
|
+
"imaps",
|
|
5241
|
+
"socks5"
|
|
5242
|
+
];
|
|
5243
|
+
var MAX_DOMAIN_LENGTH = 253;
|
|
5244
|
+
var MAX_LABEL_LENGTH = 63;
|
|
5245
|
+
var FORWARD_AUTH_FIELDS = [
|
|
5246
|
+
"url",
|
|
5247
|
+
"headers",
|
|
5248
|
+
"timeoutMs",
|
|
5249
|
+
"protectedCookies",
|
|
5250
|
+
"authResponseHeaders"
|
|
5251
|
+
];
|
|
5252
|
+
var FORWARD_AUTH_REFERENCE_FIELDS = ["id"];
|
|
5253
|
+
var HTTP_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
5254
|
+
var FORWARD_AUTH_HEADER_NAME = /^[0-9A-Za-z-]+$/;
|
|
5255
|
+
var MAX_FORWARD_AUTH_URL_LENGTH = 2048;
|
|
5256
|
+
var MAX_FORWARD_AUTH_HEADERS = 32;
|
|
5257
|
+
var MAX_FORWARD_AUTH_HEADER_BYTES = 16 * 1024;
|
|
5258
|
+
var MAX_FORWARD_AUTH_RESPONSE_HEADERS = 32;
|
|
5259
|
+
var MAX_FORWARD_AUTH_COOKIES = 32;
|
|
5260
|
+
var MAX_FORWARD_AUTH_COOKIE_NAME_LENGTH = 128;
|
|
5261
|
+
var RESERVED_FORWARD_AUTH_HEADERS = /* @__PURE__ */ new Set([
|
|
5262
|
+
"host",
|
|
5263
|
+
"content-length",
|
|
5264
|
+
"connection",
|
|
5265
|
+
"keep-alive",
|
|
5266
|
+
"proxy-authenticate",
|
|
5267
|
+
"proxy-authorization",
|
|
5268
|
+
"proxy-connection",
|
|
5269
|
+
"te",
|
|
5270
|
+
"trailer",
|
|
5271
|
+
"transfer-encoding",
|
|
5272
|
+
"upgrade"
|
|
5273
|
+
]);
|
|
5274
|
+
function fail2(path, message) {
|
|
5275
|
+
throw new TlsRuleValidationError(path, message);
|
|
5276
|
+
}
|
|
5277
|
+
function failForwardAuth(path, message) {
|
|
5278
|
+
throw new TlsForwardAuthValidationError(path, message);
|
|
5279
|
+
}
|
|
5280
|
+
function hasTlsIdentity(endpoint) {
|
|
5281
|
+
return IDENTITY_FIELDS2.some((field) => endpoint[field] !== void 0);
|
|
5282
|
+
}
|
|
5283
|
+
function isEmptyEndpoint(endpoint) {
|
|
5284
|
+
return !hasTlsIdentity(endpoint) && endpoint.port === void 0;
|
|
5285
|
+
}
|
|
5286
|
+
function isPlainObject2(value) {
|
|
5287
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5288
|
+
}
|
|
5289
|
+
function validateForwardAuthHeaderName(value, path, responseHeader) {
|
|
5290
|
+
if (typeof value !== "string" || !FORWARD_AUTH_HEADER_NAME.test(value)) {
|
|
5291
|
+
failForwardAuth(path, `${path} must be a valid HTTP header name`);
|
|
5292
|
+
}
|
|
5293
|
+
const lower = value.toLowerCase();
|
|
5294
|
+
if (RESERVED_FORWARD_AUTH_HEADERS.has(lower) || lower.startsWith("x-forwarded-") || lower.startsWith("x-freestyle-") || responseHeader && (lower === "cookie" || lower === "set-cookie")) {
|
|
5295
|
+
failForwardAuth(path, `${path} may not use reserved header \`${value}\``);
|
|
5296
|
+
}
|
|
5297
|
+
}
|
|
5298
|
+
function validateHeaderNameList(value, path) {
|
|
5299
|
+
if (!Array.isArray(value)) {
|
|
5300
|
+
failForwardAuth(path, `${path} must be an array of header names`);
|
|
5301
|
+
}
|
|
5302
|
+
if (value.length > MAX_FORWARD_AUTH_RESPONSE_HEADERS) {
|
|
5303
|
+
failForwardAuth(path, `${path} may contain at most ${MAX_FORWARD_AUTH_RESPONSE_HEADERS} names`);
|
|
5304
|
+
}
|
|
5305
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5306
|
+
value.forEach((name, index) => {
|
|
5307
|
+
validateForwardAuthHeaderName(name, `${path}[${index}]`, true);
|
|
5308
|
+
const lower = name.toLowerCase();
|
|
5309
|
+
if (seen.has(lower)) {
|
|
5310
|
+
failForwardAuth(`${path}[${index}]`, `${path} contains duplicate header \`${name}\``);
|
|
5311
|
+
}
|
|
5312
|
+
seen.add(lower);
|
|
5313
|
+
});
|
|
5314
|
+
}
|
|
5315
|
+
function validateTlsForwardAuth(options) {
|
|
5316
|
+
if (!isPlainObject2(options)) {
|
|
5317
|
+
failForwardAuth("forwardAuth", "forwardAuth must be an object");
|
|
5318
|
+
}
|
|
5319
|
+
for (const field of Object.keys(options)) {
|
|
5320
|
+
if (!FORWARD_AUTH_FIELDS.includes(field)) {
|
|
5321
|
+
failForwardAuth(field, `Unknown field \`${field}\`. Expected one of: ${FORWARD_AUTH_FIELDS.join(", ")}`);
|
|
5322
|
+
}
|
|
5323
|
+
}
|
|
5324
|
+
const typed = options;
|
|
5325
|
+
if (typeof typed.url !== "string" || typed.url.length === 0 || typed.url.length > MAX_FORWARD_AUTH_URL_LENGTH) {
|
|
5326
|
+
failForwardAuth("url", "url is required and must be an absolute HTTPS URL");
|
|
5327
|
+
}
|
|
5328
|
+
let url;
|
|
5329
|
+
try {
|
|
5330
|
+
url = new URL(typed.url);
|
|
5331
|
+
} catch {
|
|
5332
|
+
failForwardAuth("url", "url must be an absolute HTTPS URL");
|
|
5333
|
+
}
|
|
5334
|
+
if (url.protocol !== "https:" || url.hostname.length === 0 || url.username.length > 0 || url.password.length > 0 || url.hash.length > 0 || url.port === "0") {
|
|
5335
|
+
failForwardAuth("url", "url must be an absolute HTTPS URL");
|
|
5336
|
+
}
|
|
5337
|
+
if (typed.headers !== void 0) {
|
|
5338
|
+
if (!isPlainObject2(typed.headers)) {
|
|
5339
|
+
failForwardAuth("headers", "headers must be an object of header name to value");
|
|
5340
|
+
}
|
|
5341
|
+
const entries = Object.entries(typed.headers);
|
|
5342
|
+
if (entries.length > MAX_FORWARD_AUTH_HEADERS) {
|
|
5343
|
+
failForwardAuth("headers", `headers may contain at most ${MAX_FORWARD_AUTH_HEADERS} entries`);
|
|
5344
|
+
}
|
|
5345
|
+
let totalBytes = 0;
|
|
5346
|
+
const seenNames = /* @__PURE__ */ new Set();
|
|
5347
|
+
for (const [name, value] of entries) {
|
|
5348
|
+
validateForwardAuthHeaderName(name, `headers.${name}`, false);
|
|
5349
|
+
const lower = name.toLowerCase();
|
|
5350
|
+
if (seenNames.has(lower)) {
|
|
5351
|
+
failForwardAuth(`headers.${name}`, `headers contains duplicate header \`${name}\``);
|
|
5352
|
+
}
|
|
5353
|
+
seenNames.add(lower);
|
|
5354
|
+
if (typeof value !== "string" || /[\0\r\n]/.test(value)) {
|
|
5355
|
+
failForwardAuth(`headers.${name}`, `headers.${name} must be a string`);
|
|
5356
|
+
}
|
|
5357
|
+
totalBytes += name.length + value.length;
|
|
5358
|
+
}
|
|
5359
|
+
if (totalBytes > MAX_FORWARD_AUTH_HEADER_BYTES) {
|
|
5360
|
+
failForwardAuth("headers", `headers may contain at most ${MAX_FORWARD_AUTH_HEADER_BYTES} bytes`);
|
|
5361
|
+
}
|
|
5362
|
+
}
|
|
5363
|
+
if (typed.timeoutMs !== void 0 && (!Number.isInteger(typed.timeoutMs) || typed.timeoutMs < 100 || typed.timeoutMs > 5e3)) {
|
|
5364
|
+
failForwardAuth("timeoutMs", "timeoutMs must be an integer between 100 and 5000");
|
|
5365
|
+
}
|
|
5366
|
+
if (typed.protectedCookies !== void 0) {
|
|
5367
|
+
if (!Array.isArray(typed.protectedCookies)) {
|
|
5368
|
+
failForwardAuth("protectedCookies", "protectedCookies must be an array of cookie names");
|
|
5369
|
+
}
|
|
5370
|
+
if (typed.protectedCookies.length > MAX_FORWARD_AUTH_COOKIES) {
|
|
5371
|
+
failForwardAuth("protectedCookies", `protectedCookies may contain at most ${MAX_FORWARD_AUTH_COOKIES} names`);
|
|
5372
|
+
}
|
|
5373
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5374
|
+
typed.protectedCookies.forEach((name, index) => {
|
|
5375
|
+
if (typeof name !== "string" || name.length > MAX_FORWARD_AUTH_COOKIE_NAME_LENGTH || !HTTP_TOKEN.test(name)) {
|
|
5376
|
+
failForwardAuth(`protectedCookies[${index}]`, `protectedCookies[${index}] must be a valid HTTP cookie name`);
|
|
5377
|
+
}
|
|
5378
|
+
if (seen.has(name)) {
|
|
5379
|
+
failForwardAuth(`protectedCookies[${index}]`, `protectedCookies contains duplicate cookie \`${name}\``);
|
|
5380
|
+
}
|
|
5381
|
+
seen.add(name);
|
|
5382
|
+
});
|
|
5383
|
+
}
|
|
5384
|
+
if (typed.authResponseHeaders !== void 0) {
|
|
5385
|
+
validateHeaderNameList(typed.authResponseHeaders, "authResponseHeaders");
|
|
5386
|
+
}
|
|
5387
|
+
return typed;
|
|
5388
|
+
}
|
|
5389
|
+
function validateTlsForwardAuthReference(value, path) {
|
|
5390
|
+
if (!isPlainObject2(value)) {
|
|
5391
|
+
fail2(path, `${path} must be an object with an \`id\``);
|
|
5392
|
+
}
|
|
5393
|
+
for (const field of Object.keys(value)) {
|
|
5394
|
+
if (!FORWARD_AUTH_REFERENCE_FIELDS.includes(field)) {
|
|
5395
|
+
fail2(`${path}.${field}`, `${path} has unknown field \`${field}\`. Expected: ${FORWARD_AUTH_REFERENCE_FIELDS.join(", ")}`);
|
|
5396
|
+
}
|
|
5397
|
+
}
|
|
5398
|
+
const id2 = value.id;
|
|
5399
|
+
if (typeof id2 !== "string" || id2.length === 0) {
|
|
5400
|
+
fail2(`${path}.id`, `${path}.id is required and must be a non-empty string`);
|
|
5401
|
+
}
|
|
5402
|
+
}
|
|
5403
|
+
function validateTlsDomain(domain) {
|
|
5404
|
+
if (typeof domain !== "string") {
|
|
5405
|
+
fail2("domain", "domain is required and must be a string");
|
|
5406
|
+
}
|
|
5407
|
+
if (domain === "*")
|
|
5408
|
+
return;
|
|
5409
|
+
const bare = domain.startsWith("*.") ? domain.slice(2) : domain;
|
|
5410
|
+
if (bare.length === 0 || bare.length > MAX_DOMAIN_LENGTH) {
|
|
5411
|
+
fail2("domain", `domain \`${domain}\` is not a hostname`);
|
|
5412
|
+
}
|
|
5413
|
+
const labels = bare.split(".");
|
|
5414
|
+
if (labels.length < 2) {
|
|
5415
|
+
fail2("domain", `domain \`${domain}\` must have at least two labels, e.g. acme.com`);
|
|
5416
|
+
}
|
|
5417
|
+
for (const label of labels) {
|
|
5418
|
+
if (label.length === 0 || label.length > MAX_LABEL_LENGTH) {
|
|
5419
|
+
fail2("domain", `domain \`${domain}\` has an empty or over-long label`);
|
|
5420
|
+
}
|
|
5421
|
+
if (!/^[a-z0-9-]+$/.test(label)) {
|
|
5422
|
+
fail2("domain", `domain \`${domain}\`: labels are lowercase letters, digits, and hyphens (a \`*.\` prefix is the only wildcard)`);
|
|
5423
|
+
}
|
|
5424
|
+
if (label.startsWith("-") || label.endsWith("-")) {
|
|
5425
|
+
fail2("domain", `domain \`${domain}\`: a label cannot start or end with a hyphen`);
|
|
5426
|
+
}
|
|
5427
|
+
}
|
|
5428
|
+
}
|
|
5429
|
+
var ATEXT_PUNCT = "!#$%&'*+-/=?^_`{|}~.";
|
|
5430
|
+
function validateTlsMailRoute(domain) {
|
|
5431
|
+
if (typeof domain !== "string") {
|
|
5432
|
+
fail2("domain", "domain is required and must be a string");
|
|
5433
|
+
}
|
|
5434
|
+
const at = domain.lastIndexOf("@");
|
|
5435
|
+
if (at === -1) {
|
|
5436
|
+
validateTlsDomain(domain);
|
|
5437
|
+
return;
|
|
5438
|
+
}
|
|
5439
|
+
const local = domain.slice(0, at);
|
|
5440
|
+
const bare = domain.slice(at + 1);
|
|
5441
|
+
if (local.length === 0 || local.length > 64) {
|
|
5442
|
+
fail2("domain", `mail route \`${domain}\`: the part before \`@\` must be 1-64 characters`);
|
|
5443
|
+
}
|
|
5444
|
+
if (local.startsWith(".") || local.endsWith(".") || local.includes("..")) {
|
|
5445
|
+
fail2("domain", `mail route \`${domain}\`: the part before \`@\` cannot start or end with a dot, or contain \`..\``);
|
|
5446
|
+
}
|
|
5447
|
+
for (const character of local) {
|
|
5448
|
+
if (!/[a-zA-Z0-9]/.test(character) && !ATEXT_PUNCT.includes(character)) {
|
|
5449
|
+
fail2("domain", `mail route \`${domain}\`: the part before \`@\` may use letters, digits, dots, and \`${ATEXT_PUNCT.slice(0, -1)}\` (quoted mailboxes are not supported)`);
|
|
5450
|
+
}
|
|
5451
|
+
}
|
|
5452
|
+
if (bare === "*") {
|
|
5453
|
+
fail2("domain", `mail route \`${domain}\`: \`*\` is not a mail domain \u2014 name the domain the mailbox lives at`);
|
|
5454
|
+
}
|
|
5455
|
+
validateTlsDomain(bare);
|
|
5456
|
+
}
|
|
5457
|
+
function validateTlsEndpoint(endpoint, side) {
|
|
5458
|
+
if (!isPlainObject2(endpoint)) {
|
|
5459
|
+
fail2(side, `${side} must be an object`);
|
|
5460
|
+
}
|
|
5461
|
+
for (const field of Object.keys(endpoint)) {
|
|
5462
|
+
if (!ENDPOINT_FIELDS2.includes(field)) {
|
|
5463
|
+
fail2(`${side}.${field}`, `${side} has unknown field \`${field}\`. Expected one of: ${ENDPOINT_FIELDS2.join(", ")}`);
|
|
5464
|
+
}
|
|
5465
|
+
}
|
|
5466
|
+
const matcher = endpoint;
|
|
5467
|
+
for (const field of ["vmId", "vpcId", "host"]) {
|
|
5468
|
+
const value = matcher[field];
|
|
5469
|
+
if (value !== void 0 && (typeof value !== "string" || value.length === 0)) {
|
|
5470
|
+
fail2(`${side}.${field}`, `${side}.${field} must be a non-empty string`);
|
|
5471
|
+
}
|
|
5472
|
+
}
|
|
5473
|
+
if (matcher.public !== void 0 && matcher.public !== true) {
|
|
5474
|
+
fail2(`${side}.public`, `${side}.public must be true when present. Omit the field to match something else`);
|
|
5475
|
+
}
|
|
5476
|
+
if (side === "source") {
|
|
5477
|
+
if (matcher.host !== void 0) {
|
|
5478
|
+
fail2("source.host", "source.host is not allowed: a source is who opens the session, not where it lands. Put `host` on the destination");
|
|
5479
|
+
}
|
|
5480
|
+
if (matcher.port !== void 0) {
|
|
5481
|
+
fail2("source.port", "source.port is not allowed: a source is who opens the session, not where it lands. Put `port` on the destination");
|
|
5482
|
+
}
|
|
5483
|
+
if (matcher.public === true && (matcher.vmId !== void 0 || matcher.vpcId !== void 0)) {
|
|
5484
|
+
fail2("source.public", "source cannot combine `public: true` with `vmId` or `vpcId`: use one alone. `public` is every client on the Internet; `vmId`/`vpcId` is a specific dialer");
|
|
5485
|
+
}
|
|
5486
|
+
} else {
|
|
5487
|
+
if (matcher.vpcId !== void 0) {
|
|
5488
|
+
fail2("destination.vpcId", "destination.vpcId is not allowed: a session lands in one place, not on a whole network. Name a `vmId`, a `host`, or `public: true` for the domain's own origin. A whole network may be a source");
|
|
5489
|
+
}
|
|
5490
|
+
const landings = [
|
|
5491
|
+
matcher.vmId !== void 0,
|
|
5492
|
+
matcher.host !== void 0,
|
|
5493
|
+
matcher.public === true
|
|
5494
|
+
].filter(Boolean).length;
|
|
5495
|
+
if (landings > 1) {
|
|
5496
|
+
fail2("destination", "destination names more than one landing: give exactly one of `vmId`, `host`, or `public: true`");
|
|
5497
|
+
}
|
|
5498
|
+
if ((matcher.vmId !== void 0 || matcher.host !== void 0) && matcher.port === void 0) {
|
|
5499
|
+
fail2("destination.port", `destination.port is required with ${matcher.vmId !== void 0 ? "`vmId`" : "`host`"}: say which port the session lands on`);
|
|
5500
|
+
}
|
|
5501
|
+
}
|
|
5502
|
+
if (matcher.port !== void 0) {
|
|
5503
|
+
if (!Number.isInteger(matcher.port) || matcher.port < 1 || matcher.port > 65535) {
|
|
5504
|
+
fail2(`${side}.port`, `${side}.port must be an integer between 1 and 65535`);
|
|
5505
|
+
}
|
|
5506
|
+
}
|
|
5507
|
+
}
|
|
5508
|
+
function transformProtocol(transform) {
|
|
5509
|
+
if ("postgres" in transform)
|
|
5510
|
+
return "postgres";
|
|
5511
|
+
if ("smtp" in transform)
|
|
5512
|
+
return "smtp";
|
|
5513
|
+
if ("socks5" in transform)
|
|
5514
|
+
return "socks5";
|
|
5515
|
+
return "http";
|
|
5516
|
+
}
|
|
5517
|
+
function validateS3Transform(value, path) {
|
|
5518
|
+
function object(value2, fields, path2) {
|
|
5519
|
+
if (!isPlainObject2(value2) || Object.keys(value2).some((key) => !fields.includes(key))) {
|
|
5520
|
+
fail2(path2, `${path2} must contain only ${fields.join(", ")}`);
|
|
5521
|
+
}
|
|
5522
|
+
}
|
|
5523
|
+
object(value, ["region", "credentials", "scope"], path);
|
|
5524
|
+
if (typeof value.region !== "string" || !/^[a-z0-9-]{1,63}$/.test(value.region)) {
|
|
5525
|
+
fail2(`${path}.region`, "invalid s3.region");
|
|
5526
|
+
}
|
|
5527
|
+
object(value.credentials, ["accessKeyId", "secretAccessKey", "sessionToken"], `${path}.credentials`);
|
|
5528
|
+
for (const field of ["accessKeyId", "secretAccessKey", "sessionToken"]) {
|
|
5529
|
+
const credential = value.credentials[field];
|
|
5530
|
+
if (field === "sessionToken" && credential === void 0)
|
|
5531
|
+
continue;
|
|
5532
|
+
if (typeof credential !== "string" || !/^[\x21-\x7e]{1,16384}$/.test(credential)) {
|
|
5533
|
+
fail2(`${path}.credentials.${field}`, "invalid s3 credentials");
|
|
5534
|
+
}
|
|
5535
|
+
}
|
|
5536
|
+
if (!/^[a-zA-Z0-9]+$/.test(value.credentials.accessKeyId)) {
|
|
5537
|
+
fail2(`${path}.credentials.accessKeyId`, "invalid s3 access key id");
|
|
5538
|
+
}
|
|
5539
|
+
object(value.scope, ["bucket", "prefix", "operations"], `${path}.scope`);
|
|
5540
|
+
const { bucket, prefix, operations } = value.scope;
|
|
5541
|
+
if (typeof bucket !== "string" || !/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket) || bucket.includes("..")) {
|
|
5542
|
+
fail2(`${path}.scope.bucket`, "invalid s3.scope.bucket");
|
|
5543
|
+
}
|
|
5544
|
+
if (typeof prefix !== "string" || Buffer.byteLength(prefix, "utf8") > 1024 || /[\x00-\x1f\x7f-\x9f]/u.test(prefix)) {
|
|
5545
|
+
fail2(`${path}.scope.prefix`, "invalid s3.scope.prefix");
|
|
5546
|
+
}
|
|
5547
|
+
const allowed = ["GetObject", "HeadObject", "PutObject", "DeleteObject", "ListObjectsV2", "CreateMultipartUpload", "UploadPart", "ListParts", "CompleteMultipartUpload", "AbortMultipartUpload", "ListMultipartUploads"];
|
|
5548
|
+
if (!Array.isArray(operations) || operations.length === 0 || operations.length > allowed.length || operations.some((op) => !allowed.includes(op)) || new Set(operations).size !== operations.length) {
|
|
5549
|
+
fail2(`${path}.scope.operations`, "s3.scope.operations must be a nonempty list of distinct supported operations");
|
|
5550
|
+
}
|
|
5551
|
+
}
|
|
5552
|
+
function validateTlsTransform(transform, path) {
|
|
5553
|
+
if (!isPlainObject2(transform)) {
|
|
5554
|
+
fail2(path, `${path} must be an object: \`{ headers: { \u2026 } }\`, \`{ postgres: { \u2026 } }\`, \`{ smtp: { \u2026 } }\` or \`{ socks5: { \u2026 } }\``);
|
|
5555
|
+
}
|
|
5556
|
+
const keys = Object.keys(transform);
|
|
5557
|
+
if (keys.length !== 1 || !["headers", "jsonPatch", "postgres", "smtp", "socks5", "s3"].includes(keys[0] ?? "")) {
|
|
5558
|
+
fail2(path, `${path} must have exactly one of \`headers\`, \`jsonPatch\`, \`postgres\`, \`smtp\`, \`socks5\` or \`s3\` (got: ${keys.length === 0 ? "nothing" : keys.join(", ")})`);
|
|
5559
|
+
}
|
|
5560
|
+
if (keys[0] === "s3") {
|
|
5561
|
+
validateS3Transform(transform.s3, `${path}.s3`);
|
|
5562
|
+
return;
|
|
5563
|
+
}
|
|
5564
|
+
if (keys[0] === "jsonPatch") {
|
|
5565
|
+
const patch = transform.jsonPatch;
|
|
5566
|
+
if (!Array.isArray(patch) || patch.length > 128) {
|
|
5567
|
+
fail2(path, `${path}.jsonPatch must be an array of at most 128 operations`);
|
|
5568
|
+
}
|
|
5569
|
+
const pointer = (value) => typeof value === "string" && (value === "" || value.startsWith("/")) && !/~(?:[^01]|$)/u.test(value);
|
|
5570
|
+
const jsonValue = (value, seen = /* @__PURE__ */ new Set(), depth = 0) => {
|
|
5571
|
+
if (depth > 64)
|
|
5572
|
+
return false;
|
|
5573
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
5574
|
+
return true;
|
|
5575
|
+
if (typeof value === "number")
|
|
5576
|
+
return Number.isFinite(value);
|
|
5577
|
+
if (typeof value !== "object" || seen.has(value))
|
|
5578
|
+
return false;
|
|
5579
|
+
seen.add(value);
|
|
5580
|
+
const valid = (Array.isArray(value) ? Array.from(value) : isPlainObject2(value) ? Object.values(value) : [void 0]).every((entry) => jsonValue(entry, seen, depth + 1));
|
|
5581
|
+
seen.delete(value);
|
|
5582
|
+
return valid;
|
|
5583
|
+
};
|
|
5584
|
+
for (const [index, operation] of patch.entries()) {
|
|
5585
|
+
if (!isPlainObject2(operation) || !["add", "remove", "replace", "move", "copy", "test"].includes(String(operation.op)) || !pointer(operation.path)) {
|
|
5586
|
+
fail2(path, `${path}.jsonPatch[${index}] requires a standard op and valid JSON Pointer path`);
|
|
5587
|
+
}
|
|
5588
|
+
if (["move", "copy"].includes(operation.op) && !pointer(operation.from)) {
|
|
5589
|
+
fail2(path, `${path}.jsonPatch[${index}].from must be a JSON Pointer`);
|
|
5590
|
+
}
|
|
5591
|
+
if (["add", "replace", "test"].includes(operation.op) && (!Object.hasOwn(operation, "value") || !jsonValue(operation.value))) {
|
|
5592
|
+
fail2(path, `${path}.jsonPatch[${index}].value must be a JSON value (null is allowed)`);
|
|
5593
|
+
}
|
|
5594
|
+
}
|
|
5595
|
+
let encoded;
|
|
5596
|
+
try {
|
|
5597
|
+
encoded = JSON.stringify(patch);
|
|
5598
|
+
} catch {
|
|
5599
|
+
fail2(path, "jsonPatch must be JSON serializable");
|
|
5600
|
+
}
|
|
5601
|
+
if (Buffer.byteLength(encoded, "utf8") > 64 * 1024)
|
|
5602
|
+
fail2(path, "jsonPatch exceeds 64 KiB");
|
|
5603
|
+
return;
|
|
5604
|
+
}
|
|
5605
|
+
if (keys[0] === "socks5") {
|
|
5606
|
+
const socks5 = transform.socks5;
|
|
5607
|
+
if (!isPlainObject2(socks5)) {
|
|
5608
|
+
fail2(`${path}.socks5`, `${path}.socks5 must be an object with \`username\` and \`password\``);
|
|
5609
|
+
}
|
|
5610
|
+
for (const field of Object.keys(socks5)) {
|
|
5611
|
+
if (!["username", "password"].includes(field)) {
|
|
5612
|
+
fail2(`${path}.socks5.${field}`, `${path}.socks5 has unknown field \`${field}\`. Expected: username, password`);
|
|
5613
|
+
}
|
|
5614
|
+
}
|
|
5615
|
+
for (const field of ["username", "password"]) {
|
|
5616
|
+
if (typeof socks5[field] !== "string" || socks5[field].length === 0) {
|
|
5617
|
+
fail2(`${path}.socks5.${field}`, `${path}.socks5.${field} is required and must be a non-empty string`);
|
|
5618
|
+
}
|
|
5619
|
+
}
|
|
5620
|
+
return;
|
|
5621
|
+
}
|
|
5622
|
+
if (keys[0] === "smtp") {
|
|
5623
|
+
const smtp = transform.smtp;
|
|
5624
|
+
if (!isPlainObject2(smtp)) {
|
|
5625
|
+
fail2(`${path}.smtp`, `${path}.smtp must be an object with \`username\` and \`password\``);
|
|
5626
|
+
}
|
|
5627
|
+
for (const field of Object.keys(smtp)) {
|
|
5628
|
+
if (!["username", "password"].includes(field)) {
|
|
5629
|
+
fail2(`${path}.smtp.${field}`, `${path}.smtp has unknown field \`${field}\`. Expected: username, password`);
|
|
5630
|
+
}
|
|
5631
|
+
}
|
|
5632
|
+
for (const field of ["username", "password"]) {
|
|
5633
|
+
if (typeof smtp[field] !== "string" || smtp[field].length === 0) {
|
|
5634
|
+
fail2(`${path}.smtp.${field}`, `${path}.smtp.${field} is required and must be a non-empty string`);
|
|
5635
|
+
}
|
|
5636
|
+
}
|
|
5637
|
+
return;
|
|
5638
|
+
}
|
|
5639
|
+
if (keys[0] === "headers") {
|
|
5640
|
+
const headers = transform.headers;
|
|
5641
|
+
if (!isPlainObject2(headers)) {
|
|
5642
|
+
fail2(`${path}.headers`, `${path}.headers must be an object of header name to value`);
|
|
5643
|
+
}
|
|
5644
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
5645
|
+
if (typeof value !== "string") {
|
|
5646
|
+
fail2(`${path}.headers.${name}`, `${path}.headers.${name} must be a string`);
|
|
5647
|
+
}
|
|
5648
|
+
}
|
|
5649
|
+
} else {
|
|
5650
|
+
const postgres2 = transform.postgres;
|
|
5651
|
+
if (!isPlainObject2(postgres2)) {
|
|
5652
|
+
fail2(`${path}.postgres`, `${path}.postgres must be an object with \`username\` and \`password\``);
|
|
5653
|
+
}
|
|
5654
|
+
for (const field of Object.keys(postgres2)) {
|
|
5655
|
+
if (!["username", "password", "database"].includes(field)) {
|
|
5656
|
+
fail2(`${path}.postgres.${field}`, `${path}.postgres has unknown field \`${field}\`. Expected: username, password, database`);
|
|
5657
|
+
}
|
|
5658
|
+
}
|
|
5659
|
+
const pg = postgres2;
|
|
5660
|
+
for (const field of ["username", "password"]) {
|
|
5661
|
+
if (typeof pg[field] !== "string" || pg[field].length === 0) {
|
|
5662
|
+
fail2(`${path}.postgres.${field}`, `${path}.postgres.${field} is required and must be a non-empty string`);
|
|
5663
|
+
}
|
|
5664
|
+
}
|
|
5665
|
+
if (pg.database !== void 0 && typeof pg.database !== "string") {
|
|
5666
|
+
fail2(`${path}.postgres.database`, `${path}.postgres.database must be a string`);
|
|
5667
|
+
}
|
|
5668
|
+
}
|
|
5669
|
+
}
|
|
5670
|
+
function resolveTlsProtocol(protocol, transforms) {
|
|
5671
|
+
let resolved = protocol;
|
|
5672
|
+
transforms.forEach((transform, index) => {
|
|
5673
|
+
const needed = transformProtocol(transform);
|
|
5674
|
+
if (resolved === void 0) {
|
|
5675
|
+
resolved = needed;
|
|
5676
|
+
} else if (resolved !== needed) {
|
|
5677
|
+
fail2(`transform[${index}]`, `transform requires protocol \`${needed}\` but the rule is \`${resolved}\`; a rule terminates one protocol`);
|
|
5678
|
+
}
|
|
5679
|
+
});
|
|
5680
|
+
return resolved ?? "http";
|
|
5681
|
+
}
|
|
5682
|
+
function validateHttpRequestMatch(value, path) {
|
|
5683
|
+
if (!isPlainObject2(value) || Object.keys(value).some((key) => key !== "method" && key !== "path")) {
|
|
5684
|
+
fail2(path, `${path} must be an object containing only method and path`);
|
|
5685
|
+
}
|
|
5686
|
+
if (value.method !== void 0 && (!Array.isArray(value.method) || value.method.some((method) => typeof method !== "string" || method.length === 0 || /[^!#$%&'*+.^_`|~0-9A-Za-z-]/.test(method)))) {
|
|
5687
|
+
fail2(`${path}.method`, `${path}.method must be an array of HTTP method tokens`);
|
|
5688
|
+
}
|
|
5689
|
+
if (value.path !== void 0) {
|
|
5690
|
+
const matcher = value.path;
|
|
5691
|
+
if (!isPlainObject2(matcher) || Object.keys(matcher).length !== 1 || typeof matcher.exact !== "string" || !matcher.exact.startsWith("/") || /[\s?#]/.test(matcher.exact)) {
|
|
5692
|
+
fail2(`${path}.path`, `${path}.path must contain exact: an absolute path without a query or fragment`);
|
|
5693
|
+
}
|
|
5694
|
+
}
|
|
5695
|
+
}
|
|
5696
|
+
var RULE_FIELDS2 = [
|
|
5697
|
+
"action",
|
|
5698
|
+
"domain",
|
|
5699
|
+
"protocol",
|
|
5700
|
+
"source",
|
|
5701
|
+
"destination",
|
|
5702
|
+
"transform",
|
|
5703
|
+
"match",
|
|
5704
|
+
"forwardAuth"
|
|
5705
|
+
];
|
|
5706
|
+
function validateRuleShape2(rule, prefix) {
|
|
5707
|
+
if (!isPlainObject2(rule)) {
|
|
5708
|
+
fail2(prefix || "rule", `${prefix || "rule"} must be an object`);
|
|
5709
|
+
}
|
|
5710
|
+
for (const field of Object.keys(rule)) {
|
|
5711
|
+
if (!RULE_FIELDS2.includes(field)) {
|
|
5712
|
+
fail2(`${prefix}${field}`, `Unknown field \`${field}\`. Expected one of: ${RULE_FIELDS2.join(", ")}`);
|
|
5713
|
+
}
|
|
5714
|
+
}
|
|
5715
|
+
const typed = rule;
|
|
5716
|
+
if (typed.action === void 0) {
|
|
5717
|
+
fail2(`${prefix}action`, `${prefix}action is required. Write \`action: "allow"\` \u2014 every rule states what it does rather than defaulting to it`);
|
|
5718
|
+
}
|
|
5719
|
+
if (typed.action !== "allow") {
|
|
5720
|
+
fail2(`${prefix}action`, `${prefix}action must be "allow" (got \`${String(typed.action)}\`). Only allow rules exist today`);
|
|
5721
|
+
}
|
|
5722
|
+
if (typed.protocol !== void 0 && !PROTOCOLS2.includes(typed.protocol)) {
|
|
5723
|
+
fail2(`${prefix}protocol`, `${prefix}protocol must be one of: ${PROTOCOLS2.join(", ")} (got \`${String(typed.protocol)}\`)`);
|
|
5724
|
+
}
|
|
5725
|
+
const source = typed.source ?? {};
|
|
5726
|
+
const destination = typed.destination ?? {};
|
|
5727
|
+
validateTlsEndpoint(source, "source");
|
|
5728
|
+
validateTlsEndpoint(destination, "destination");
|
|
5729
|
+
const transforms = typed.transform ?? [];
|
|
5730
|
+
if (!Array.isArray(transforms)) {
|
|
5731
|
+
fail2(`${prefix}transform`, `${prefix}transform must be an array`);
|
|
5732
|
+
}
|
|
5733
|
+
transforms.forEach((transform, index) => validateTlsTransform(transform, `${prefix}transform[${index}]`));
|
|
5734
|
+
const protocol = resolveTlsProtocol(typed.protocol, transforms);
|
|
5735
|
+
if (typed.match !== void 0) {
|
|
5736
|
+
validateHttpRequestMatch(typed.match, `${prefix}match`);
|
|
5737
|
+
if (protocol !== "http" || source.public === true || !(destination.public === true || destination.host !== void 0)) {
|
|
5738
|
+
fail2(`${prefix}match`, "match requires HTTP egress from a VM or VPC to a public origin or host");
|
|
5739
|
+
}
|
|
5740
|
+
}
|
|
5741
|
+
if (transforms.some((entry) => "s3" in entry)) {
|
|
5742
|
+
if (transforms.length !== 1 || typed.match !== void 0 || source.public === true || !(destination.public === true || destination.host !== void 0) || typed.domain.includes("*")) {
|
|
5743
|
+
fail2(`${prefix}transform`, "s3 requires a sole transform on HTTP egress to a concrete origin, without match");
|
|
5744
|
+
}
|
|
5745
|
+
}
|
|
5746
|
+
const patches = transforms.filter((entry) => "jsonPatch" in entry);
|
|
5747
|
+
if (patches.length > 1) {
|
|
5748
|
+
fail2(`${prefix}transform`, "a rule may carry only one jsonPatch transform");
|
|
5749
|
+
}
|
|
5750
|
+
if (patches.length && (source.public === true || !(destination.public === true || destination.host !== void 0))) {
|
|
5751
|
+
fail2(`${prefix}transform`, "jsonPatch requires HTTP egress from a VM or VPC to a public origin or host");
|
|
5752
|
+
}
|
|
5753
|
+
if (typed.forwardAuth !== void 0) {
|
|
5754
|
+
validateTlsForwardAuthReference(typed.forwardAuth, `${prefix}forwardAuth`);
|
|
5755
|
+
const destinationIsVm = destination.vmId !== void 0 || !hasTlsIdentity(destination) && destination.port !== void 0;
|
|
5756
|
+
if (protocol !== "http" || source.public !== true || !destinationIsVm || typed.domain === "*") {
|
|
5757
|
+
fail2(`${prefix}forwardAuth`, `${prefix}forwardAuth is only supported on HTTP public ingress: \`{ public: true } -> { vmId, port }\``);
|
|
5758
|
+
}
|
|
5759
|
+
}
|
|
5760
|
+
if (protocol === "smtp") {
|
|
5761
|
+
validateTlsMailRoute(typed.domain);
|
|
5762
|
+
} else {
|
|
5763
|
+
validateTlsDomain(typed.domain);
|
|
5764
|
+
}
|
|
5765
|
+
const internalService = (source.vmId !== void 0 || source.vpcId !== void 0) && destination.vmId !== void 0;
|
|
5766
|
+
if (internalService && transforms.length > 0) {
|
|
5767
|
+
fail2(`${prefix}transform`, "a VM-to-VM rule cannot carry a transform yet \u2014 remove it (only public ingress/egress rules run transforms)");
|
|
5768
|
+
}
|
|
5769
|
+
if (protocol === "tcp") {
|
|
5770
|
+
const destinationIsWorld = destination.public === true || destination.host !== void 0;
|
|
5771
|
+
const sourceIsIdentity = source.vmId !== void 0 || source.vpcId !== void 0;
|
|
5772
|
+
if (destinationIsWorld || sourceIsIdentity) {
|
|
5773
|
+
fail2(`${prefix}protocol`, "a `tcp` rule is public ingress only \u2014 `{ public: true } -> { vmId, port }`. The edge splices the session without terminating it, so outbound there is nothing to broker (an outbound allow is a firewall rule, and injecting into one needs `http` or `postgres`) and inward there is no certificate to answer the source with. Give the source `public: true`, or drop `protocol` to be served as HTTPS");
|
|
5774
|
+
}
|
|
5775
|
+
} else if (protocol === "imap" || protocol === "imaps") {
|
|
5776
|
+
const destinationIsWorld = destination.public === true || destination.host !== void 0;
|
|
5777
|
+
const sourceIsIdentity = source.vmId !== void 0 || source.vpcId !== void 0;
|
|
5778
|
+
if (destinationIsWorld || sourceIsIdentity) {
|
|
5779
|
+
fail2(`${prefix}protocol`, `an \`${protocol}\` rule is public ingress only \u2014 \`{ public: true } -> { vmId, port }\`. The 993 front serves mail clients reaching a VM's IMAP server; outbound there is nothing to broker (an outbound allow is a firewall rule) and inward there is no certificate to answer the source with`);
|
|
5780
|
+
}
|
|
5781
|
+
} else if (protocol === "socks5") {
|
|
5782
|
+
const destinationIsWorld = destination.public === true || destination.host !== void 0;
|
|
5783
|
+
const sourceIsIdentity = source.public !== true;
|
|
5784
|
+
if (!sourceIsIdentity || !destinationIsWorld) {
|
|
5785
|
+
fail2(`${prefix}protocol`, 'a `socks5` rule is egress only \u2014 `{ vmId } -> { host: "gate.provider.com", port: 1080 }`. The front exists so a guest can open sessions through a proxy whose credentials it never holds; inbound there is nothing to serve, and publishing one would be an open proxy on a platform address');
|
|
5786
|
+
}
|
|
5787
|
+
if (destination.host === void 0) {
|
|
5788
|
+
fail2(`${prefix}destination`, "a `socks5` rule has to name the proxy it goes through: give the destination a `host` and the `port` the provider listens on. `{ public: true }` on its own asks the edge to be the proxy, which would let the guest out on our address rather than through yours");
|
|
5789
|
+
}
|
|
5790
|
+
} else if (protocol === "postgres") {
|
|
5791
|
+
const injects = transforms.some((transform) => transform !== null && typeof transform === "object" && "postgres" in transform);
|
|
5792
|
+
const destinationIsWorld = destination.public === true || destination.host !== void 0;
|
|
5793
|
+
if (injects && !destinationIsWorld) {
|
|
5794
|
+
fail2(`${prefix}transform`, "a `postgres` transform belongs on an egress rule: the edge spends a credential the guest never holds, so pointing it at your own VM would hand every client a logged-in session. Give the destination `public: true` or a `host`, or drop the transform");
|
|
5795
|
+
}
|
|
5796
|
+
} else if (protocol === "smtp") {
|
|
5797
|
+
const injects = transforms.some((transform) => transform !== null && typeof transform === "object" && "smtp" in transform);
|
|
5798
|
+
const destinationIsWorld = destination.public === true || destination.host !== void 0;
|
|
5799
|
+
if (injects && !destinationIsWorld) {
|
|
5800
|
+
fail2(`${prefix}transform`, "an `smtp` transform belongs on an egress rule: the edge spends a credential the guest never holds, so pointing it at your own VM would turn it into an open relay. Give the destination `public: true` or a `host`, or drop the transform");
|
|
5801
|
+
}
|
|
5802
|
+
if (typeof typed.domain === "string" && typed.domain.includes("@") && source.public !== true) {
|
|
5803
|
+
fail2(`${prefix}domain`, `\`${typed.domain}\` names a mailbox, which only makes sense on a rule that receives mail. An egress or VM-to-VM rule names the host the guest submits through \u2014 drop the part before \`@\``);
|
|
5804
|
+
}
|
|
5805
|
+
}
|
|
5806
|
+
return { ...typed, source, destination };
|
|
5807
|
+
}
|
|
5808
|
+
function missingIdentityMessage(side, endpoint) {
|
|
5809
|
+
if (side === "source") {
|
|
5810
|
+
return isEmptyEndpoint(endpoint) ? "source is empty. Say who opens the session: `vmId`, `vpcId`, or `public: true`" : "source only narrows the session without saying who opens it. Add `vmId`, `vpcId`, or `public: true`";
|
|
5811
|
+
}
|
|
5812
|
+
return "destination is empty. Say where the session lands: `vmId` + `port`, `host` + `port`, or `public: true` for the domain's own origin";
|
|
5813
|
+
}
|
|
5814
|
+
function validateTlsRule(rule) {
|
|
5815
|
+
const typed = validateRuleShape2(rule, "");
|
|
5816
|
+
for (const side of ["source", "destination"]) {
|
|
5817
|
+
const endpoint = typed[side];
|
|
5818
|
+
if (!hasTlsIdentity(endpoint)) {
|
|
5819
|
+
fail2(side, missingIdentityMessage(side, endpoint));
|
|
5820
|
+
}
|
|
5821
|
+
}
|
|
5822
|
+
if (typed.source.public === true && typed.destination.public === true) {
|
|
5823
|
+
fail2("destination", "a rule from the public Internet to the domain's own origin allows nothing the world cannot already do. Did you mean a `vmId` destination (ingress) or a `vmId` source (egress)?");
|
|
5824
|
+
}
|
|
5825
|
+
return typed;
|
|
5826
|
+
}
|
|
5827
|
+
function validateTlsSpec(spec) {
|
|
5828
|
+
if (!isPlainObject2(spec)) {
|
|
5829
|
+
fail2("tls", "tls must be an object with a `rules` array");
|
|
5830
|
+
}
|
|
5831
|
+
for (const field of Object.keys(spec)) {
|
|
5832
|
+
if (field !== "rules") {
|
|
5833
|
+
fail2(`tls.${field}`, `tls has unknown field \`${field}\`. Expected: rules`);
|
|
5834
|
+
}
|
|
5835
|
+
}
|
|
5836
|
+
const rules = spec.rules;
|
|
5837
|
+
if (!Array.isArray(rules)) {
|
|
5838
|
+
fail2("tls.rules", "tls.rules must be an array");
|
|
5839
|
+
}
|
|
5840
|
+
rules.forEach((rule, index) => {
|
|
5841
|
+
const prefix = `tls.rules[${index}].`;
|
|
5842
|
+
const typed = validateRuleShape2(rule, prefix);
|
|
5843
|
+
const sourceNames = hasTlsIdentity(typed.source);
|
|
5844
|
+
const destinationNames = hasTlsIdentity(typed.destination);
|
|
5845
|
+
if (!sourceNames && !destinationNames) {
|
|
5846
|
+
fail2(`tls.rules[${index}]`, `tls.rules[${index}]: both source and destination refer to the VM being created, which allows nothing. Give one of them a \`vmId\`, \`vpcId\`, or \`public: true\``);
|
|
5847
|
+
}
|
|
5848
|
+
if (sourceNames && destinationNames) {
|
|
5849
|
+
fail2(`tls.rules[${index}]`, `tls.rules[${index}]: neither end refers to the VM being created. Leave one endpoint's identity unset so it means this VM, or create the rule with tls.rules.create instead`);
|
|
5850
|
+
}
|
|
5851
|
+
if (!destinationNames && typed.destination.port === void 0) {
|
|
5852
|
+
fail2(`tls.rules[${index}].destination.port`, `tls.rules[${index}]: destination.port is required: say which port on this VM the session lands on`);
|
|
5853
|
+
}
|
|
5854
|
+
});
|
|
5855
|
+
return spec;
|
|
5856
|
+
}
|
|
5857
|
+
function tlsRulePath(ruleId) {
|
|
5858
|
+
return `/v5/tls/${segment(ruleId)}`;
|
|
5859
|
+
}
|
|
5860
|
+
function tlsForwardAuthPath(id2) {
|
|
5861
|
+
return `/v5/tls/forward-auth/${segment(id2)}`;
|
|
5862
|
+
}
|
|
5863
|
+
var TlsForwardAuthNamespace = class {
|
|
5864
|
+
client;
|
|
5865
|
+
constructor(client) {
|
|
5866
|
+
this.client = client;
|
|
5867
|
+
}
|
|
5868
|
+
/** List your forward-auth configurations, newest first. */
|
|
5869
|
+
list() {
|
|
5870
|
+
return this.client.get("/v5/tls/forward-auth");
|
|
5871
|
+
}
|
|
5872
|
+
/** Create a forward-auth configuration. Static request-header values are write-only. */
|
|
5873
|
+
async create(options) {
|
|
5874
|
+
const validated = validateTlsForwardAuth(options);
|
|
5875
|
+
return this.client.post("/v5/tls/forward-auth", validated);
|
|
5876
|
+
}
|
|
5877
|
+
/** Fetch one forward-auth configuration by id. */
|
|
5878
|
+
get(id2) {
|
|
5879
|
+
return this.client.get(tlsForwardAuthPath(id2));
|
|
5880
|
+
}
|
|
5881
|
+
/** Replace a configuration in place, including rotating its static headers. */
|
|
5882
|
+
async update(id2, options) {
|
|
5883
|
+
const validated = validateTlsForwardAuth(options);
|
|
5884
|
+
return this.client.put(tlsForwardAuthPath(id2), validated);
|
|
5885
|
+
}
|
|
5886
|
+
/** Delete an unused forward-auth configuration. */
|
|
5887
|
+
delete(id2) {
|
|
5888
|
+
return this.client.delete(tlsForwardAuthPath(id2));
|
|
5889
|
+
}
|
|
5890
|
+
};
|
|
5891
|
+
var TlsRulesNamespace = class {
|
|
5892
|
+
client;
|
|
5893
|
+
constructor(client) {
|
|
5894
|
+
this.client = client;
|
|
5895
|
+
}
|
|
5896
|
+
/**
|
|
5897
|
+
* Allow a named TLS session: `domain`, from `source`, to `destination`.
|
|
5898
|
+
*
|
|
5899
|
+
* An ingress `domain` is a name you have verified, or any unused subdomain
|
|
5900
|
+
* of `style.dev`, which is free and needs no verification or DNS records.
|
|
5901
|
+
*
|
|
5902
|
+
* @example
|
|
5903
|
+
* // Public HTTPS ingress: anyone dialing app.acme.com lands on a VM's port.
|
|
5904
|
+
* await freestyle.tls.rules.create({
|
|
5905
|
+
* action: "allow",
|
|
5906
|
+
* domain: "app.acme.com",
|
|
5907
|
+
* source: { public: true },
|
|
5908
|
+
* destination: { vmId: "vm-123456", port: 8000 },
|
|
5909
|
+
* });
|
|
5910
|
+
*
|
|
5911
|
+
* @example
|
|
5912
|
+
* // Egress with a secret injected: a VM reaches OpenAI at its real origin,
|
|
5913
|
+
* // and the edge adds the Authorization header the guest never held.
|
|
5914
|
+
* await freestyle.tls.rules.create({
|
|
5915
|
+
* action: "allow",
|
|
5916
|
+
* domain: "api.openai.com",
|
|
5917
|
+
* source: { vmId: "vm-123456" },
|
|
5918
|
+
* destination: { public: true },
|
|
5919
|
+
* transform: [{ headers: { authorization: "Bearer sk-…" } }],
|
|
5920
|
+
* });
|
|
5921
|
+
*
|
|
5922
|
+
* @example
|
|
5923
|
+
* // Internal service: one VM reaches another by a name. The source opens
|
|
5924
|
+
* // https://api.internal; the edge terminates it and forwards to the target
|
|
5925
|
+
* // VM's port. Steered at the edge, HTTP-only, no transform.
|
|
5926
|
+
* await freestyle.tls.rules.create({
|
|
5927
|
+
* action: "allow",
|
|
5928
|
+
* domain: "api.internal",
|
|
5929
|
+
* source: { vmId: "vm-123456" },
|
|
5930
|
+
* destination: { vmId: "vm-654321", port: 8000 },
|
|
5931
|
+
* });
|
|
5932
|
+
*
|
|
5933
|
+
* @throws {TlsRuleValidationError} before any request, when the rule cannot be
|
|
5934
|
+
* well formed.
|
|
5935
|
+
*/
|
|
5936
|
+
// `async` so a validation failure arrives as a rejected promise like every
|
|
5937
|
+
// other error from this client, instead of a synchronous throw that a
|
|
5938
|
+
// `.catch()` chain would sail straight past.
|
|
5939
|
+
async create(options) {
|
|
5940
|
+
const validated = validateTlsRule(options);
|
|
5941
|
+
return this.client.post("/v5/tls", validated);
|
|
5942
|
+
}
|
|
5943
|
+
/**
|
|
5944
|
+
* List your rules, newest first. Pass `vmId` for the rules that apply to one
|
|
5945
|
+
* VM — those naming it, plus those naming a private network it is on.
|
|
5946
|
+
*/
|
|
5947
|
+
list(options = {}) {
|
|
5948
|
+
return this.client.get("/v5/tls", options);
|
|
5949
|
+
}
|
|
5950
|
+
get(ruleId) {
|
|
5951
|
+
return this.client.get(tlsRulePath(ruleId));
|
|
5952
|
+
}
|
|
5953
|
+
/**
|
|
5954
|
+
* Replace a rule in place. The one mutation the firewall does not allow: a
|
|
5955
|
+
* transform carries a rotating secret, and delete-then-create would drop
|
|
5956
|
+
* traffic or trip the account limit mid-swap. The rule keeps its id and
|
|
5957
|
+
* creation time; everything in `options` is replaced.
|
|
5958
|
+
*
|
|
5959
|
+
* @throws {TlsRuleValidationError} before any request, when the replacement
|
|
5960
|
+
* cannot be well formed.
|
|
5961
|
+
*/
|
|
5962
|
+
async update(ruleId, options) {
|
|
5963
|
+
const validated = validateTlsRule(options);
|
|
5964
|
+
return this.client.put(tlsRulePath(ruleId), validated);
|
|
5965
|
+
}
|
|
5966
|
+
/** Delete a rule. The resources it named are untouched. */
|
|
5967
|
+
delete(ruleId) {
|
|
5968
|
+
return this.client.delete(tlsRulePath(ruleId));
|
|
5969
|
+
}
|
|
5970
|
+
};
|
|
5971
|
+
var TlsNamespace = class {
|
|
5972
|
+
client;
|
|
5973
|
+
rules;
|
|
5974
|
+
forwardAuth;
|
|
5975
|
+
constructor(client) {
|
|
5976
|
+
this.client = client;
|
|
5977
|
+
this.rules = new TlsRulesNamespace(client);
|
|
5978
|
+
this.forwardAuth = new TlsForwardAuthNamespace(client);
|
|
5979
|
+
}
|
|
5980
|
+
};
|
|
5981
|
+
|
|
5982
|
+
// ../../node_modules/.bun/freestyle@0.2.16/node_modules/freestyle/dist/tunnel.js
|
|
5983
|
+
function tunnelPath(idOrSlug) {
|
|
5984
|
+
return `/v5/tunnels/${segment(idOrSlug)}`;
|
|
5985
|
+
}
|
|
5986
|
+
function withTunnelId(tunnel) {
|
|
5987
|
+
tunnel.tunnelId ??= tunnel.id;
|
|
5988
|
+
return tunnel;
|
|
5989
|
+
}
|
|
5990
|
+
var TunnelsNamespace = class {
|
|
5991
|
+
client;
|
|
5992
|
+
constructor(client) {
|
|
5993
|
+
this.client = client;
|
|
5994
|
+
}
|
|
5995
|
+
/** List your tunnels, newest first. Listings never include a private key. */
|
|
5996
|
+
list() {
|
|
5997
|
+
return this.client.get("/v5/tunnels").then((result) => {
|
|
5998
|
+
result.tunnels.forEach(withTunnelId);
|
|
5999
|
+
return result;
|
|
6000
|
+
});
|
|
6001
|
+
}
|
|
6002
|
+
/**
|
|
6003
|
+
* Create a tunnel, optionally attaching networks in the same call
|
|
6004
|
+
* (`vpcs`, all-or-nothing). This is a one-time setup: the tunnel lives
|
|
6005
|
+
* until you delete it and its config never changes — save it, bring it up
|
|
6006
|
+
* once, and manage which networks it reaches by attaching and detaching
|
|
6007
|
+
* them.
|
|
6008
|
+
*/
|
|
6009
|
+
create(options = {}) {
|
|
6010
|
+
return this.client.post("/v5/tunnels", options).then(withTunnelId);
|
|
6011
|
+
}
|
|
6012
|
+
/** Read one tunnel by its id or its slug. Its config comes back with a blank `PrivateKey`. */
|
|
6013
|
+
get(tunnelIdOrSlug) {
|
|
6014
|
+
return this.client.get(tunnelPath(tunnelIdOrSlug)).then(withTunnelId);
|
|
6015
|
+
}
|
|
6016
|
+
/**
|
|
6017
|
+
* Rename a tunnel or change its slug. Only the labels move: the keys,
|
|
6018
|
+
* addresses, routes and attached networks are untouched, so a connected
|
|
6019
|
+
* client sees nothing change and the config you saved keeps working.
|
|
6020
|
+
*/
|
|
6021
|
+
update(tunnelIdOrSlug, options) {
|
|
6022
|
+
return this.client.patch(tunnelPath(tunnelIdOrSlug), options).then(withTunnelId);
|
|
6023
|
+
}
|
|
6024
|
+
/**
|
|
6025
|
+
* Replace a tunnel's keys, keeping its id and every attached network's
|
|
6026
|
+
* address. Use this if you lost the config or need to revoke one that
|
|
6027
|
+
* leaked.
|
|
6028
|
+
*/
|
|
6029
|
+
rotateKey(tunnelIdOrSlug, options = {}) {
|
|
6030
|
+
return this.client.post(`${tunnelPath(tunnelIdOrSlug)}/rotate-key`, options).then(withTunnelId);
|
|
6031
|
+
}
|
|
6032
|
+
/** Delete a tunnel, detaching every network as it goes. */
|
|
6033
|
+
delete(tunnelIdOrSlug) {
|
|
6034
|
+
return this.client.delete(tunnelPath(tunnelIdOrSlug));
|
|
6035
|
+
}
|
|
6036
|
+
/**
|
|
6037
|
+
* Attach a private network to a tunnel. The tunnel gains an address inside
|
|
6038
|
+
* the network — what that network's VMs see as your address — and a
|
|
6039
|
+
* connected client can reach it immediately, with no config change. The
|
|
6040
|
+
* network's CIDRs must fall inside the tunnel's routes and must not
|
|
6041
|
+
* overlap another attached network's.
|
|
6042
|
+
*/
|
|
6043
|
+
attachVpc(tunnelIdOrSlug, vpcIdOrSlug, options = {}) {
|
|
6044
|
+
return this.client.post(`${tunnelPath(tunnelIdOrSlug)}/vpcs/${segment(vpcIdOrSlug)}`, options).then(withTunnelId);
|
|
6045
|
+
}
|
|
6046
|
+
/**
|
|
6047
|
+
* Detach a private network from a tunnel: it stops being reachable and the
|
|
6048
|
+
* tunnel's address inside it is released. The tunnel — and any connected
|
|
6049
|
+
* client — carries on untouched.
|
|
6050
|
+
*/
|
|
6051
|
+
detachVpc(tunnelIdOrSlug, vpcIdOrSlug) {
|
|
6052
|
+
return this.client.delete(`${tunnelPath(tunnelIdOrSlug)}/vpcs/${segment(vpcIdOrSlug)}`).then(withTunnelId);
|
|
6053
|
+
}
|
|
6054
|
+
};
|
|
6055
|
+
|
|
6056
|
+
// ../../node_modules/.bun/freestyle@0.2.16/node_modules/freestyle/dist/vms/fs.js
|
|
6057
|
+
var RESUMABLE_THRESHOLD_BYTES = 8 * 1024 * 1024;
|
|
6058
|
+
var DEFAULT_CHUNK_BYTES = 16 * 1024 * 1024;
|
|
6059
|
+
var DEFAULT_MAX_ATTEMPTS = 5;
|
|
6060
|
+
var VmFilesystem = class {
|
|
6061
|
+
client;
|
|
6062
|
+
basePath;
|
|
6063
|
+
constructor(client, basePath) {
|
|
6064
|
+
this.client = client;
|
|
6065
|
+
this.basePath = basePath;
|
|
6066
|
+
}
|
|
6067
|
+
/**
|
|
6068
|
+
* Read a whole file, or with `offset`/`length` just part of one, into memory.
|
|
6069
|
+
*
|
|
6070
|
+
* The result is a plain `Uint8Array` on every runtime — never a Node
|
|
6071
|
+
* `Buffer`, even on Node. Don't coerce it with `String(bytes)` or
|
|
6072
|
+
* `bytes.toString()`: those go through the typed-array `toString` and
|
|
6073
|
+
* produce the byte values joined by commas ("123,10,32,…"), not the file's
|
|
6074
|
+
* text. For text use {@link readTextFile}, or decode explicitly with
|
|
6075
|
+
* `new TextDecoder().decode(bytes)`.
|
|
6076
|
+
*/
|
|
6077
|
+
async readFile(path, options = {}) {
|
|
6078
|
+
const response = await this.openRead(path, options);
|
|
6079
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
6080
|
+
}
|
|
6081
|
+
/** Read a file as UTF-8 text. Use this, not `String(await readFile(...))`. */
|
|
6082
|
+
async readTextFile(path, options = {}) {
|
|
6083
|
+
const response = await this.openRead(path, options);
|
|
6084
|
+
return await response.text();
|
|
6085
|
+
}
|
|
6086
|
+
/**
|
|
6087
|
+
* Stream a file out of the VM without holding it in memory — pipe it to disk,
|
|
6088
|
+
* to an HTTP response, or through a parser.
|
|
6089
|
+
*
|
|
6090
|
+
* ```ts
|
|
6091
|
+
* const bytes = await vm.fs.readFileStream("/var/log/big.log");
|
|
6092
|
+
* await pipeline(Readable.fromWeb(bytes), createWriteStream("big.log"));
|
|
6093
|
+
* ```
|
|
6094
|
+
*/
|
|
6095
|
+
async readFileStream(path, options = {}) {
|
|
6096
|
+
const response = await this.openRead(path, options);
|
|
6097
|
+
if (!response.body)
|
|
6098
|
+
throw new Error(`no response body reading ${path}`);
|
|
6099
|
+
return response.body;
|
|
6100
|
+
}
|
|
6101
|
+
/**
|
|
6102
|
+
* Write a file into the VM, whatever its size.
|
|
6103
|
+
*
|
|
6104
|
+
* ```ts
|
|
6105
|
+
* await vm.fs.writeFile("/etc/app.conf", "debug = true");
|
|
6106
|
+
* await vm.fs.writeFile("/data/model.bin", await openAsBlob("model.bin"), {
|
|
6107
|
+
* onProgress: ({ completedBytes, totalBytes }) =>
|
|
6108
|
+
* console.log(`${completedBytes}/${totalBytes}`),
|
|
6109
|
+
* });
|
|
6110
|
+
* ```
|
|
6111
|
+
*
|
|
6112
|
+
* A `Blob` (`node:fs`'s `openAsBlob`, or a browser `File`) is read one chunk at
|
|
6113
|
+
* a time and never held in memory whole.
|
|
6114
|
+
*/
|
|
6115
|
+
async writeFile(path, content, options = {}) {
|
|
6116
|
+
const body = typeof content === "string" ? new TextEncoder().encode(content) : content;
|
|
6117
|
+
const size = body instanceof Uint8Array ? body.byteLength : body.size;
|
|
6118
|
+
if (size > RESUMABLE_THRESHOLD_BYTES) {
|
|
6119
|
+
return this.uploadInChunks(path, body, size, options);
|
|
6120
|
+
}
|
|
6121
|
+
const bytes = body instanceof Uint8Array ? body : new Uint8Array(await body.arrayBuffer());
|
|
6122
|
+
await this.client.putBytes(`${this.basePath}/fs/write`, bytes, { path, mode: options.mode, sha256: await sha256Hex(bytes) }, options.signal);
|
|
6123
|
+
options.onProgress?.({ completedBytes: size, totalBytes: size });
|
|
6124
|
+
}
|
|
6125
|
+
/** Write text. Identical to {@link writeFile} with a string. */
|
|
6126
|
+
writeTextFile(path, content, options = {}) {
|
|
6127
|
+
return this.writeFile(path, content, options);
|
|
6128
|
+
}
|
|
6129
|
+
async readDir(path) {
|
|
6130
|
+
const { entries } = await this.client.get(`${this.basePath}/fs/dir`, { path });
|
|
6131
|
+
return entries;
|
|
6132
|
+
}
|
|
6133
|
+
async mkdir(path) {
|
|
6134
|
+
await this.client.post(`${this.basePath}/fs/mkdir`, { path });
|
|
6135
|
+
}
|
|
6136
|
+
async remove(path) {
|
|
6137
|
+
await this.client.delete(`${this.basePath}/fs/remove`, { path });
|
|
6138
|
+
}
|
|
6139
|
+
async exists(path) {
|
|
6140
|
+
const { exists } = await this.client.get(`${this.basePath}/fs/exists`, { path });
|
|
6141
|
+
return exists;
|
|
6142
|
+
}
|
|
6143
|
+
stat(path) {
|
|
6144
|
+
return this.client.get(`${this.basePath}/fs/stat`, { path });
|
|
6145
|
+
}
|
|
6146
|
+
openRead(path, options) {
|
|
6147
|
+
const { offset, length } = options;
|
|
6148
|
+
let range;
|
|
6149
|
+
if (offset !== void 0 || length !== void 0) {
|
|
6150
|
+
const start = offset ?? 0;
|
|
6151
|
+
range = length === void 0 ? `bytes=${start}-` : `bytes=${start}-${start + length - 1}`;
|
|
6152
|
+
}
|
|
6153
|
+
return this.client.getBytes(`${this.basePath}/fs/read`, { path }, { range, signal: options.signal });
|
|
6154
|
+
}
|
|
6155
|
+
/**
|
|
6156
|
+
* Send a large file as an upload session: ordered chunks, each retried
|
|
6157
|
+
* independently with backoff, so one dropped connection costs one chunk rather
|
|
6158
|
+
* than the whole transfer. The file appears at `path` only on commit.
|
|
6159
|
+
*/
|
|
6160
|
+
async uploadInChunks(path, content, size, options) {
|
|
6161
|
+
const chunkSize = options.chunkSize ?? DEFAULT_CHUNK_BYTES;
|
|
6162
|
+
const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
6163
|
+
const sha256 = content instanceof Uint8Array ? await sha256Hex(content) : void 0;
|
|
6164
|
+
const session = await this.client.post(`${this.basePath}/fs/uploads`, {
|
|
6165
|
+
path,
|
|
6166
|
+
size,
|
|
6167
|
+
sha256,
|
|
6168
|
+
mode: options.mode
|
|
6169
|
+
});
|
|
6170
|
+
const uploadPath = `${this.basePath}/fs/uploads/${session.uploadId}`;
|
|
6171
|
+
try {
|
|
6172
|
+
let offset = session.receivedBytes;
|
|
6173
|
+
while (offset < size) {
|
|
6174
|
+
const end = Math.min(offset + chunkSize, size);
|
|
6175
|
+
const chunk = content instanceof Uint8Array ? content.subarray(offset, end) : content.slice(offset, end);
|
|
6176
|
+
const status = await this.putChunkWithRetry(uploadPath, chunk, offset, maxAttempts, options.signal);
|
|
6177
|
+
offset = status.receivedBytes;
|
|
6178
|
+
options.onProgress?.({ completedBytes: Math.min(offset, size), totalBytes: size });
|
|
6179
|
+
}
|
|
6180
|
+
await this.client.post(`${uploadPath}/commit`);
|
|
6181
|
+
} catch (error) {
|
|
6182
|
+
await this.client.delete(uploadPath).catch(() => {
|
|
6183
|
+
});
|
|
6184
|
+
throw error;
|
|
6185
|
+
}
|
|
6186
|
+
}
|
|
6187
|
+
/**
|
|
6188
|
+
* PUT one chunk, retrying transient failures. After any failure the session
|
|
6189
|
+
* is re-fetched: the server only counts fully-stored chunks, so its
|
|
6190
|
+
* `receivedBytes` tells us whether this chunk actually landed (return) or
|
|
6191
|
+
* must be resent (retry).
|
|
6192
|
+
*/
|
|
6193
|
+
async putChunkWithRetry(uploadPath, chunk, offset, maxAttempts, signal) {
|
|
6194
|
+
let lastError;
|
|
6195
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
6196
|
+
signal?.throwIfAborted?.();
|
|
6197
|
+
try {
|
|
6198
|
+
return await this.client.putBytes(uploadPath, chunk, { offset }, signal);
|
|
6199
|
+
} catch (error) {
|
|
6200
|
+
lastError = error;
|
|
6201
|
+
const retryable = !(error instanceof FreestyleApiError) || error.status >= 500 || error.status === 408 || error.status === 409 || error.status === 429;
|
|
6202
|
+
if (!retryable || signal?.aborted || attempt === maxAttempts)
|
|
6203
|
+
throw error;
|
|
6204
|
+
await sleep(Math.min(500 * 2 ** (attempt - 1), 8e3));
|
|
6205
|
+
const status = await this.client.get(uploadPath).catch(() => void 0);
|
|
6206
|
+
if (status && status.receivedBytes > offset)
|
|
6207
|
+
return status;
|
|
6208
|
+
}
|
|
6209
|
+
}
|
|
6210
|
+
throw lastError;
|
|
6211
|
+
}
|
|
6212
|
+
};
|
|
6213
|
+
async function sha256Hex(bytes) {
|
|
6214
|
+
const subtle = globalThis.crypto?.subtle;
|
|
6215
|
+
if (!subtle)
|
|
6216
|
+
return void 0;
|
|
6217
|
+
const digest = await subtle.digest("SHA-256", bytes);
|
|
6218
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
6219
|
+
}
|
|
6220
|
+
function sleep(ms) {
|
|
6221
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
6222
|
+
}
|
|
6223
|
+
|
|
6224
|
+
// ../../node_modules/.bun/freestyle@0.2.16/node_modules/freestyle/dist/vms/pty.js
|
|
6225
|
+
function isNodeRuntime2() {
|
|
6226
|
+
return typeof process !== "undefined" && Boolean(process.versions?.node);
|
|
6227
|
+
}
|
|
6228
|
+
async function openSocket(url, headers) {
|
|
6229
|
+
if (isNodeRuntime2()) {
|
|
6230
|
+
let wsModule;
|
|
6231
|
+
try {
|
|
6232
|
+
wsModule = await import("./wrapper-GH5LMTDE.js");
|
|
6233
|
+
} catch {
|
|
6234
|
+
throw new Error("PTY sessions need the `ws` package in Node. Install it with `npm install ws`.");
|
|
6235
|
+
}
|
|
6236
|
+
return new wsModule.default(url, { headers });
|
|
6237
|
+
}
|
|
6238
|
+
const NativeWebSocket = globalThis.WebSocket;
|
|
6239
|
+
if (!NativeWebSocket) {
|
|
6240
|
+
throw new Error("PTY sessions need a WebSocket client; no global WebSocket is available in this environment.");
|
|
6241
|
+
}
|
|
6242
|
+
return new NativeWebSocket(url);
|
|
6243
|
+
}
|
|
6244
|
+
function onSocketEvent(socket, event, listener) {
|
|
6245
|
+
if (socket.on) {
|
|
6246
|
+
socket.on(event, listener);
|
|
6247
|
+
return;
|
|
6248
|
+
}
|
|
6249
|
+
socket.addEventListener?.(event, listener);
|
|
6250
|
+
}
|
|
6251
|
+
var utf8Decoder = new TextDecoder();
|
|
6252
|
+
function normalizeMessage(raw, isBinary) {
|
|
6253
|
+
if (raw && typeof raw === "object" && "data" in raw) {
|
|
6254
|
+
const data = raw.data;
|
|
6255
|
+
if (typeof data === "string")
|
|
6256
|
+
return { text: data };
|
|
6257
|
+
if (data instanceof ArrayBuffer)
|
|
6258
|
+
return { binary: new Uint8Array(data) };
|
|
6259
|
+
return {};
|
|
6260
|
+
}
|
|
6261
|
+
if (raw instanceof Uint8Array)
|
|
6262
|
+
return isBinary ? { binary: raw } : { text: utf8Decoder.decode(raw) };
|
|
6263
|
+
if (typeof raw === "string")
|
|
6264
|
+
return { text: raw };
|
|
6265
|
+
return {};
|
|
6266
|
+
}
|
|
6267
|
+
var PtySession = class {
|
|
6268
|
+
_sessionId;
|
|
6269
|
+
_slug;
|
|
6270
|
+
_created = false;
|
|
6271
|
+
socket;
|
|
6272
|
+
constructor(socket, sessionId, events = {}) {
|
|
6273
|
+
this.socket = socket;
|
|
6274
|
+
this._sessionId = sessionId;
|
|
6275
|
+
onSocketEvent(socket, "message", (raw, isBinary) => {
|
|
6276
|
+
const { text, binary } = normalizeMessage(raw, isBinary);
|
|
6277
|
+
if (binary) {
|
|
6278
|
+
events.onData?.(binary);
|
|
6279
|
+
return;
|
|
6280
|
+
}
|
|
6281
|
+
if (!text)
|
|
6282
|
+
return;
|
|
6283
|
+
let msg;
|
|
6284
|
+
try {
|
|
6285
|
+
msg = JSON.parse(text);
|
|
6286
|
+
} catch {
|
|
6287
|
+
return;
|
|
6288
|
+
}
|
|
6289
|
+
if (msg.type === "exited")
|
|
6290
|
+
events.onExit?.(msg.exitCode);
|
|
6291
|
+
else if (msg.type === "error")
|
|
6292
|
+
events.onError?.(new Error(msg.message));
|
|
6293
|
+
else if (msg.type === "sessionInfo") {
|
|
6294
|
+
this._sessionId = msg.sessionId;
|
|
6295
|
+
this._slug = msg.slug;
|
|
6296
|
+
this._created = msg.created ?? true;
|
|
6297
|
+
events.onSessionInfo?.(msg.sessionId);
|
|
6298
|
+
}
|
|
6299
|
+
});
|
|
6300
|
+
onSocketEvent(socket, "close", (raw) => {
|
|
6301
|
+
const info = raw;
|
|
6302
|
+
events.onClose?.({
|
|
6303
|
+
wasClean: info?.wasClean ?? true,
|
|
6304
|
+
code: info?.code ?? 1e3,
|
|
6305
|
+
reason: info?.reason ?? ""
|
|
6306
|
+
});
|
|
6307
|
+
});
|
|
6308
|
+
onSocketEvent(socket, "error", (error) => events.onError?.(error));
|
|
6309
|
+
}
|
|
6310
|
+
/** Send raw bytes (or UTF-8 text) to the guest's stdin. */
|
|
6311
|
+
write(data) {
|
|
6312
|
+
this.socket.send(typeof data === "string" ? new TextEncoder().encode(data) : data);
|
|
6313
|
+
}
|
|
6314
|
+
resize(options) {
|
|
6315
|
+
this.sendControl({ type: "resize", cols: options.cols, rows: options.rows });
|
|
6316
|
+
}
|
|
6317
|
+
signal(sig) {
|
|
6318
|
+
this.sendControl({ type: "signal", signal: sig });
|
|
6319
|
+
}
|
|
6320
|
+
sendControl(msg) {
|
|
6321
|
+
this.socket.send(JSON.stringify(msg));
|
|
6322
|
+
}
|
|
6323
|
+
/** Close the connection. The shell keeps running; a later `attach()` can reconnect. */
|
|
6324
|
+
detach() {
|
|
6325
|
+
this.socket.close();
|
|
6326
|
+
}
|
|
6327
|
+
get sessionId() {
|
|
6328
|
+
return this._sessionId;
|
|
6329
|
+
}
|
|
6330
|
+
/**
|
|
6331
|
+
* The name this session answers to, from the server's `sessionInfo` frame.
|
|
6332
|
+
* `undefined` for a session opened without a slug — reattach to that one by
|
|
6333
|
+
* `sessionId` instead.
|
|
6334
|
+
*/
|
|
6335
|
+
get slug() {
|
|
6336
|
+
return this._slug;
|
|
6337
|
+
}
|
|
6338
|
+
/**
|
|
6339
|
+
* Whether `open()` started this shell, or handed back one that was already
|
|
6340
|
+
* running under the same `slug`. `false` means your `exec` never ran — check
|
|
6341
|
+
* it before waiting on output from a command you thought you started.
|
|
6342
|
+
*/
|
|
6343
|
+
get created() {
|
|
6344
|
+
return this._created;
|
|
6345
|
+
}
|
|
6346
|
+
get readyState() {
|
|
6347
|
+
return this.socket.readyState;
|
|
6348
|
+
}
|
|
6349
|
+
};
|
|
6350
|
+
function waitForOpen(socket) {
|
|
6351
|
+
return new Promise((resolve, reject) => {
|
|
6352
|
+
if (socket.readyState === 1) {
|
|
6353
|
+
resolve();
|
|
6354
|
+
return;
|
|
6355
|
+
}
|
|
6356
|
+
onSocketEvent(socket, "open", () => resolve());
|
|
6357
|
+
onSocketEvent(socket, "error", (error) => reject(error));
|
|
6358
|
+
});
|
|
6359
|
+
}
|
|
6360
|
+
var VmPty = class {
|
|
6361
|
+
client;
|
|
6362
|
+
basePath;
|
|
6363
|
+
constructor(client, basePath) {
|
|
6364
|
+
this.client = client;
|
|
6365
|
+
this.basePath = basePath;
|
|
6366
|
+
}
|
|
6367
|
+
/**
|
|
6368
|
+
* Open a new terminal on the VM. The returned session's data/exit
|
|
6369
|
+
* listeners are live from the moment the socket connects — installed
|
|
6370
|
+
* before we even know the session id — so a command that produces output
|
|
6371
|
+
* (or exits) immediately can never race past them.
|
|
6372
|
+
*/
|
|
6373
|
+
async open(options = {}) {
|
|
6374
|
+
const { exec, cols, rows, linuxUser, slug, replaceOnExit, onData, onExit, onClose, onError } = options;
|
|
6375
|
+
const query = {};
|
|
6376
|
+
if (exec !== void 0)
|
|
6377
|
+
query.exec = exec;
|
|
6378
|
+
if (cols !== void 0)
|
|
6379
|
+
query.cols = cols;
|
|
6380
|
+
if (rows !== void 0)
|
|
6381
|
+
query.rows = rows;
|
|
6382
|
+
if (linuxUser !== void 0)
|
|
6383
|
+
query.linuxUser = linuxUser;
|
|
6384
|
+
if (slug !== void 0)
|
|
6385
|
+
query.slug = slug;
|
|
6386
|
+
if (replaceOnExit !== void 0)
|
|
6387
|
+
query.replaceOnExit = String(replaceOnExit);
|
|
6388
|
+
const url = wsUrl(this.client, `${this.basePath}/pty`, query);
|
|
6389
|
+
const headers = this.client.authHeaders();
|
|
6390
|
+
const socket = await openSocket(url, headers);
|
|
6391
|
+
return new Promise((resolve, reject) => {
|
|
6392
|
+
let settled = false;
|
|
6393
|
+
const session = new PtySession(socket, -1, {
|
|
6394
|
+
onData,
|
|
6395
|
+
onExit,
|
|
6396
|
+
onClose: (info) => {
|
|
6397
|
+
if (!settled) {
|
|
6398
|
+
settled = true;
|
|
6399
|
+
reject(new Error("connection closed before sessionInfo"));
|
|
6400
|
+
}
|
|
6401
|
+
onClose?.(info);
|
|
6402
|
+
},
|
|
6403
|
+
onError: (error) => {
|
|
6404
|
+
if (!settled) {
|
|
6405
|
+
settled = true;
|
|
6406
|
+
reject(error);
|
|
6407
|
+
}
|
|
6408
|
+
onError?.(error);
|
|
6409
|
+
},
|
|
6410
|
+
onSessionInfo: () => {
|
|
6411
|
+
settled = true;
|
|
6412
|
+
resolve(session);
|
|
6413
|
+
}
|
|
6414
|
+
});
|
|
6415
|
+
});
|
|
6416
|
+
}
|
|
6417
|
+
/** Reattach to an existing session; retained scrollback replays first. */
|
|
6418
|
+
async attach(options) {
|
|
6419
|
+
const { session: selector, linuxUser, onData, onExit, onClose, onError } = options;
|
|
6420
|
+
const url = wsUrl(this.client, `${this.basePath}/pty/sessions/${segment(String(selector))}`, {
|
|
6421
|
+
...linuxUser !== void 0 ? { linuxUser } : {}
|
|
6422
|
+
});
|
|
6423
|
+
const headers = this.client.authHeaders();
|
|
6424
|
+
const socket = await openSocket(url, headers);
|
|
6425
|
+
const session = new PtySession(socket, typeof selector === "number" ? selector : -1, {
|
|
6426
|
+
onData,
|
|
6427
|
+
onExit,
|
|
6428
|
+
onClose,
|
|
6429
|
+
onError
|
|
6430
|
+
});
|
|
6431
|
+
await waitForOpen(socket);
|
|
6432
|
+
return session;
|
|
6433
|
+
}
|
|
6434
|
+
/** List the VM's terminal sessions, running and recently exited. */
|
|
6435
|
+
list(options = {}) {
|
|
6436
|
+
return this.client.get(`${this.basePath}/pty/sessions`, options);
|
|
6437
|
+
}
|
|
6438
|
+
/** Kill a terminal session and remove it. */
|
|
6439
|
+
close(session, options = {}) {
|
|
6440
|
+
return this.client.delete(`${this.basePath}/pty/sessions/${segment(String(session))}`, options);
|
|
6441
|
+
}
|
|
6442
|
+
};
|
|
6443
|
+
var VmLinuxUserPty = class {
|
|
6444
|
+
pty;
|
|
6445
|
+
linuxUser;
|
|
6446
|
+
constructor(pty, linuxUser) {
|
|
6447
|
+
this.pty = pty;
|
|
6448
|
+
this.linuxUser = linuxUser;
|
|
6449
|
+
}
|
|
6450
|
+
open(options = {}) {
|
|
6451
|
+
return this.pty.open({ ...options, linuxUser: this.linuxUser });
|
|
6452
|
+
}
|
|
6453
|
+
attach(options) {
|
|
6454
|
+
return this.pty.attach({ ...options, linuxUser: this.linuxUser });
|
|
6455
|
+
}
|
|
6456
|
+
list() {
|
|
6457
|
+
return this.pty.list({ linuxUser: this.linuxUser });
|
|
6458
|
+
}
|
|
6459
|
+
close(session) {
|
|
6460
|
+
return this.pty.close(session, { linuxUser: this.linuxUser });
|
|
6461
|
+
}
|
|
6462
|
+
};
|
|
6463
|
+
function wsUrl(client, path, query) {
|
|
6464
|
+
const httpUrl = client.url(path, query);
|
|
6465
|
+
return httpUrl.replace(/^http/, "ws");
|
|
6466
|
+
}
|
|
6467
|
+
|
|
6468
|
+
// ../../node_modules/.bun/freestyle@0.2.16/node_modules/freestyle/dist/vms/snapshots.js
|
|
6469
|
+
var VmSnapshotsNamespace = class {
|
|
6470
|
+
client;
|
|
6471
|
+
constructor(client) {
|
|
6472
|
+
this.client = client;
|
|
6473
|
+
}
|
|
6474
|
+
list(options = {}) {
|
|
6475
|
+
return this.client.get("/v5/snapshots", options);
|
|
6476
|
+
}
|
|
6477
|
+
get(snapshotId) {
|
|
6478
|
+
return this.client.get(`/v5/snapshots/${segment(snapshotId)}`);
|
|
6479
|
+
}
|
|
6480
|
+
update(snapshotId, options) {
|
|
6481
|
+
return this.client.patch(`/v5/snapshots/${segment(snapshotId)}`, options);
|
|
6482
|
+
}
|
|
6483
|
+
delete(snapshotId) {
|
|
6484
|
+
return this.client.delete(`/v5/snapshots/${segment(snapshotId)}`);
|
|
6485
|
+
}
|
|
6486
|
+
};
|
|
6487
|
+
|
|
6488
|
+
// ../../node_modules/.bun/freestyle@0.2.16/node_modules/freestyle/dist/vms/index.js
|
|
6489
|
+
function vmPath(id2) {
|
|
6490
|
+
return `/v5/vms/${segment(id2)}`;
|
|
6491
|
+
}
|
|
6492
|
+
var Vm = class {
|
|
6493
|
+
id;
|
|
6494
|
+
fs;
|
|
6495
|
+
pty;
|
|
6496
|
+
client;
|
|
6497
|
+
basePath;
|
|
6498
|
+
constructor(client, id2) {
|
|
6499
|
+
this.client = client;
|
|
6500
|
+
this.id = id2;
|
|
6501
|
+
this.basePath = vmPath(id2);
|
|
6502
|
+
this.fs = new VmFilesystem(client, this.basePath);
|
|
6503
|
+
this.pty = new VmPty(client, this.basePath);
|
|
6504
|
+
}
|
|
6505
|
+
/** Fetch the current VM record. */
|
|
6506
|
+
data() {
|
|
6507
|
+
return this.client.get(this.basePath);
|
|
6508
|
+
}
|
|
6509
|
+
/**
|
|
6510
|
+
* The firewall rules that apply to this VM: those naming it, plus those
|
|
6511
|
+
* naming a private network it is attached to.
|
|
6512
|
+
*
|
|
6513
|
+
* Deleting the VM deletes the rules that name it, so this never reports a
|
|
6514
|
+
* rule over a machine that no longer exists.
|
|
6515
|
+
*/
|
|
6516
|
+
firewallRules() {
|
|
6517
|
+
return this.client.get("/v5/firewall/rules", { vmId: this.id });
|
|
6518
|
+
}
|
|
6519
|
+
/** Rename the VM, change its slug or idle timeout, or merge in metadata. */
|
|
6520
|
+
update(options) {
|
|
6521
|
+
return this.client.patch(this.basePath, options);
|
|
6522
|
+
}
|
|
6523
|
+
/** Boot a stopped VM, or resume a paused one. */
|
|
6524
|
+
start() {
|
|
6525
|
+
return this.client.post(`${this.basePath}/start`);
|
|
6526
|
+
}
|
|
6527
|
+
/** Freeze a running VM, keeping its memory so a later start resumes it exactly. */
|
|
6528
|
+
pause() {
|
|
6529
|
+
return this.client.post(`${this.basePath}/pause`);
|
|
6530
|
+
}
|
|
6531
|
+
/**
|
|
6532
|
+
* Change vCPU, memory, or disk. Every axis is grow-only. vCPU and memory
|
|
6533
|
+
* apply live to a running VM, on resume for a paused one, and at the next
|
|
6534
|
+
* boot for a stopped one. Growing the disk needs a running VM.
|
|
6535
|
+
*/
|
|
6536
|
+
resize(options) {
|
|
6537
|
+
return this.client.post(`${this.basePath}/resize`, options);
|
|
6538
|
+
}
|
|
6539
|
+
/** Permanently destroy the VM */
|
|
6540
|
+
delete() {
|
|
6541
|
+
return this.client.delete(this.basePath);
|
|
6542
|
+
}
|
|
6543
|
+
/**
|
|
6544
|
+
* Run a command in the guest and wait for it to finish. A non-zero exit
|
|
6545
|
+
* status is still a successful call — check `statusCode`, which is null if
|
|
6546
|
+
* the command was killed by its timeout.
|
|
6547
|
+
*/
|
|
6548
|
+
exec(options) {
|
|
6549
|
+
const body = typeof options === "string" ? { command: options } : options;
|
|
6550
|
+
return this.client.post(`${this.basePath}/exec-await`, body);
|
|
6551
|
+
}
|
|
6552
|
+
/** Scope process and terminal operations to one existing in-guest Linux user. */
|
|
6553
|
+
linuxUser(linuxUser) {
|
|
6554
|
+
return new VmLinuxUser(this, linuxUser);
|
|
6555
|
+
}
|
|
6556
|
+
/**
|
|
6557
|
+
* Capture the VM's exact state, memory and disk. The VM must be running or
|
|
6558
|
+
* paused. The new snapshot is private and fully materialized when this
|
|
6559
|
+
* resolves.
|
|
6560
|
+
*/
|
|
6561
|
+
snapshot(options = {}) {
|
|
6562
|
+
return this.client.post(`${this.basePath}/snapshot`, options);
|
|
6563
|
+
}
|
|
6564
|
+
};
|
|
6565
|
+
var VmLinuxUser = class {
|
|
6566
|
+
vm;
|
|
6567
|
+
linuxUser;
|
|
6568
|
+
pty;
|
|
6569
|
+
constructor(vm, linuxUser) {
|
|
6570
|
+
this.vm = vm;
|
|
6571
|
+
this.linuxUser = linuxUser;
|
|
6572
|
+
this.pty = new VmLinuxUserPty(vm.pty, linuxUser);
|
|
6573
|
+
}
|
|
6574
|
+
exec(options) {
|
|
6575
|
+
const request = typeof options === "string" ? { command: options } : options;
|
|
6576
|
+
return this.vm.exec({ ...request, linuxUser: this.linuxUser });
|
|
6577
|
+
}
|
|
6578
|
+
};
|
|
6579
|
+
var VmsNamespace = class {
|
|
6580
|
+
client;
|
|
6581
|
+
snapshots;
|
|
6582
|
+
constructor(client) {
|
|
6583
|
+
this.client = client;
|
|
6584
|
+
this.snapshots = new VmSnapshotsNamespace(client);
|
|
6585
|
+
}
|
|
6586
|
+
/**
|
|
6587
|
+
* Boot a new VM.
|
|
6588
|
+
*
|
|
6589
|
+
* `firewall` is required: a VM gets nothing implicitly, so state what it may
|
|
6590
|
+
* reach. `{ rules: [] }` is a legitimate answer — a VM reachable only through
|
|
6591
|
+
* a mapped domain or SSH.
|
|
6592
|
+
*
|
|
6593
|
+
* @example
|
|
6594
|
+
* const { vm, vmId } = await freestyle.vms.create({
|
|
6595
|
+
* firewall: {
|
|
6596
|
+
* rules: [
|
|
6597
|
+
* // This VM can reach the Internet. Without it, it cannot.
|
|
6598
|
+
* { action: "allow", source: {}, destination: { public: true } },
|
|
6599
|
+
* ],
|
|
6600
|
+
* },
|
|
6601
|
+
* });
|
|
6602
|
+
* await vm.exec("echo hello");
|
|
6603
|
+
*/
|
|
6604
|
+
async create(options) {
|
|
6605
|
+
validateFirewallSpec(options.firewall, "VM");
|
|
6606
|
+
if (options.tls !== void 0) {
|
|
6607
|
+
validateTlsSpec(options.tls);
|
|
6608
|
+
}
|
|
6609
|
+
const data = await this.client.post("/v5/vms", options);
|
|
6610
|
+
return {
|
|
6611
|
+
vm: new Vm(this.client, data.id),
|
|
6612
|
+
vmId: data.id,
|
|
6613
|
+
data,
|
|
6614
|
+
// The ids of the rules the create declared — nothing else names them.
|
|
6615
|
+
firewallRules: data.firewallRules ?? [],
|
|
6616
|
+
tlsRules: data.tlsRules ?? []
|
|
6617
|
+
};
|
|
6618
|
+
}
|
|
6619
|
+
/**
|
|
6620
|
+
* List your VMs.
|
|
6621
|
+
*
|
|
6622
|
+
* @example
|
|
6623
|
+
* const { vms } = await freestyle.vms.list();
|
|
6624
|
+
*/
|
|
6625
|
+
list(options = {}) {
|
|
6626
|
+
return this.client.get("/v5/vms", options);
|
|
6627
|
+
}
|
|
6628
|
+
/**
|
|
6629
|
+
* Fetch a VM by its id or its slug.
|
|
6630
|
+
*
|
|
6631
|
+
* @example
|
|
6632
|
+
* const vm = await freestyle.vms.get("vmId");
|
|
6633
|
+
*/
|
|
6634
|
+
get(vmIdOrSlug) {
|
|
6635
|
+
return this.client.get(vmPath(vmIdOrSlug));
|
|
6636
|
+
}
|
|
6637
|
+
/** A handle to a VM, without a network call. */
|
|
6638
|
+
ref(vmIdOrSlug) {
|
|
6639
|
+
return new Vm(this.client, vmIdOrSlug);
|
|
6640
|
+
}
|
|
6641
|
+
delete(vmIdOrSlug) {
|
|
6642
|
+
return this.client.delete(vmPath(vmIdOrSlug));
|
|
6643
|
+
}
|
|
6644
|
+
};
|
|
6645
|
+
|
|
6646
|
+
// ../../node_modules/.bun/freestyle@0.2.16/node_modules/freestyle/dist/vpc.js
|
|
6647
|
+
function vpcPath(id2) {
|
|
6648
|
+
return `/v5/vpcs/${segment(id2)}`;
|
|
6649
|
+
}
|
|
6650
|
+
var VpcTunnelNamespace = class {
|
|
6651
|
+
client;
|
|
6652
|
+
basePath;
|
|
6653
|
+
constructor(client, basePath) {
|
|
6654
|
+
this.client = client;
|
|
6655
|
+
this.basePath = basePath;
|
|
6656
|
+
}
|
|
6657
|
+
/**
|
|
6658
|
+
* List the tunnels attached to this network, newest first. Listings never
|
|
6659
|
+
* include a private key: one is returned only by
|
|
6660
|
+
* `freestyle.tunnels.create` and `freestyle.tunnels.rotateKey`.
|
|
6661
|
+
*/
|
|
6662
|
+
list() {
|
|
6663
|
+
return this.client.get(`${this.basePath}/tunnels`);
|
|
6664
|
+
}
|
|
6665
|
+
};
|
|
6666
|
+
var Vpc = class {
|
|
6667
|
+
id;
|
|
6668
|
+
tunnels;
|
|
6669
|
+
client;
|
|
6670
|
+
basePath;
|
|
6671
|
+
constructor(client, id2) {
|
|
6672
|
+
this.client = client;
|
|
6673
|
+
this.id = id2;
|
|
6674
|
+
this.basePath = vpcPath(id2);
|
|
6675
|
+
this.tunnels = new VpcTunnelNamespace(client, this.basePath);
|
|
6676
|
+
}
|
|
6677
|
+
data() {
|
|
6678
|
+
return this.client.get(this.basePath);
|
|
6679
|
+
}
|
|
6680
|
+
update(options) {
|
|
6681
|
+
return this.client.patch(this.basePath, options);
|
|
6682
|
+
}
|
|
6683
|
+
delete() {
|
|
6684
|
+
return this.client.delete(this.basePath);
|
|
6685
|
+
}
|
|
6686
|
+
};
|
|
6687
|
+
var VpcNamespace = class {
|
|
6688
|
+
client;
|
|
6689
|
+
constructor(client) {
|
|
6690
|
+
this.client = client;
|
|
6691
|
+
}
|
|
6692
|
+
/** List your private networks. */
|
|
6693
|
+
list() {
|
|
6694
|
+
return this.client.get("/v5/vpcs");
|
|
6695
|
+
}
|
|
6696
|
+
/** Create a private network, optionally with the rules that say what it is for. */
|
|
6697
|
+
async create(options = {}) {
|
|
6698
|
+
if (options.firewall !== void 0)
|
|
6699
|
+
validateFirewallSpec(options.firewall, "network");
|
|
6700
|
+
const data = await this.client.post("/v5/vpcs", options);
|
|
6701
|
+
return {
|
|
6702
|
+
vpc: new Vpc(this.client, data.id),
|
|
6703
|
+
vpcId: data.id,
|
|
6704
|
+
data,
|
|
6705
|
+
firewallRules: data.firewallRules ?? []
|
|
6706
|
+
};
|
|
6707
|
+
}
|
|
6708
|
+
/** Fetch a private network by its id or its slug. */
|
|
6709
|
+
get(vpcIdOrSlug) {
|
|
6710
|
+
return this.client.get(vpcPath(vpcIdOrSlug));
|
|
6711
|
+
}
|
|
6712
|
+
/** A handle to a private network, without a network call. */
|
|
6713
|
+
ref(vpcIdOrSlug) {
|
|
6714
|
+
return new Vpc(this.client, vpcIdOrSlug);
|
|
6715
|
+
}
|
|
6716
|
+
delete(vpcIdOrSlug) {
|
|
6717
|
+
return this.client.delete(vpcPath(vpcIdOrSlug));
|
|
6718
|
+
}
|
|
6719
|
+
};
|
|
6720
|
+
|
|
6721
|
+
// ../../node_modules/.bun/freestyle@0.2.16/node_modules/freestyle/dist/index.js
|
|
6722
|
+
var Freestyle = class {
|
|
6723
|
+
client;
|
|
6724
|
+
vms;
|
|
6725
|
+
vpc;
|
|
6726
|
+
tunnels;
|
|
6727
|
+
/** Allowed network communication, stated as `source -> destination` intent. */
|
|
6728
|
+
firewall;
|
|
6729
|
+
/** Named TLS sessions, stated as `domain, source -> destination` intent. */
|
|
6730
|
+
tls;
|
|
6731
|
+
domains;
|
|
6732
|
+
identities;
|
|
6733
|
+
constructor(options = {}) {
|
|
6734
|
+
this.client = new FreestyleClient(options);
|
|
6735
|
+
this.vms = new VmsNamespace(this.client);
|
|
6736
|
+
this.vpc = new VpcNamespace(this.client);
|
|
6737
|
+
this.tunnels = new TunnelsNamespace(this.client);
|
|
6738
|
+
this.firewall = new FirewallNamespace(this.client);
|
|
6739
|
+
this.tls = new TlsNamespace(this.client);
|
|
6740
|
+
this.domains = new DomainsNamespace(this.client);
|
|
6741
|
+
this.identities = new IdentitiesNamespace(this.client);
|
|
6742
|
+
}
|
|
6743
|
+
/** Low-level escape hatch: an authenticated `fetch` against the API. Pass the whole path, `/v5` included. */
|
|
6744
|
+
fetch(path, init) {
|
|
6745
|
+
return this.client.fetch(path, init);
|
|
6746
|
+
}
|
|
6747
|
+
};
|
|
6748
|
+
|
|
6749
|
+
// ../sandbox/package.json
|
|
6750
|
+
var package_default = {
|
|
6751
|
+
name: "@decocms/sandbox",
|
|
6752
|
+
version: "1.79.0",
|
|
6753
|
+
private: true,
|
|
6754
|
+
type: "module",
|
|
6755
|
+
description: "Sandbox runtime for isolated Studio tool execution",
|
|
6756
|
+
scripts: {
|
|
6757
|
+
check: "tsc --noEmit",
|
|
6758
|
+
test: "bun test",
|
|
6759
|
+
build: "bun run build:typegen && bun run build:harness-runner",
|
|
6760
|
+
"build:typegen": "mkdir -p dist && bun run --cwd=../typegen build && bun --cwd=../typegen pm pack --filename $PWD/dist/typegen.tgz --quiet",
|
|
6761
|
+
"build:harness-runner": "mkdir -p dist && bun --cwd=../harness-runner pm pack --filename $PWD/dist/harness-runner.tgz --quiet"
|
|
6762
|
+
},
|
|
6763
|
+
exports: {
|
|
6764
|
+
"./shared": "./shared.ts",
|
|
6765
|
+
"./provider": "./server/provider/index.ts",
|
|
6766
|
+
"./provider/agent-sandbox": "./server/provider/agent-sandbox/index.ts",
|
|
6767
|
+
"./provider/postgres-state-store": "./server/provider/postgres-state-store.ts",
|
|
6768
|
+
"./provider/freestyle": "./server/provider/freestyle.ts",
|
|
6769
|
+
"./provider/remote": "./server/provider/remote.ts",
|
|
6770
|
+
"./provider/router": "./server/provider/router.ts",
|
|
6771
|
+
"./provider/sandbox-api": "./server/provider/sandbox-api.ts",
|
|
6772
|
+
"./provider/sandbox-server": "./server/provider/sandbox-server.ts",
|
|
6773
|
+
"./daemon-client": "./server/daemon-client.ts",
|
|
6774
|
+
"./org-fs": "./org-fs.ts",
|
|
6775
|
+
"./dispatch": "./dispatch/index.ts",
|
|
6776
|
+
"./dispatch/*": "./dispatch/*.ts",
|
|
6777
|
+
"./proxy/http": "./proxy/http.ts",
|
|
6778
|
+
"./proxy/websocket": "./proxy/websocket.ts"
|
|
6779
|
+
},
|
|
6780
|
+
dependencies: {
|
|
6781
|
+
"@decocms/shared": "workspace:*",
|
|
6782
|
+
"@kubernetes/client-node": "^1.4.0",
|
|
6783
|
+
"@modelcontextprotocol/sdk": "1.29.0",
|
|
6784
|
+
"@opentelemetry/api": "^1.9.0",
|
|
6785
|
+
freestyle: "0.2.16",
|
|
6786
|
+
zod: "4.3.6"
|
|
6787
|
+
},
|
|
6788
|
+
devDependencies: {
|
|
6789
|
+
"@types/bun": "latest",
|
|
6790
|
+
postgres: "^3.4.5",
|
|
6791
|
+
typescript: "^7.0.2"
|
|
6792
|
+
}
|
|
6793
|
+
};
|
|
6794
|
+
|
|
6795
|
+
// ../sandbox/server/provider/shared/proxy-metrics.ts
|
|
6796
|
+
var RUNNER_KIND = {
|
|
6797
|
+
kubernetes: "agent-sandbox",
|
|
6798
|
+
freestyle: "freestyle"
|
|
6799
|
+
};
|
|
6800
|
+
function daemonProxyTimer(meter) {
|
|
6801
|
+
const histogram = meter?.createHistogram("studio.sandbox.proxy.duration_ms", {
|
|
6802
|
+
description: "Wall-clock latency of studio-mediated requests to the sandbox daemon: tool exec proxies (source=daemon) and preview iframe traffic (source=preview).",
|
|
6803
|
+
unit: "ms"
|
|
6804
|
+
});
|
|
6805
|
+
return async (runnerKind, send) => {
|
|
6806
|
+
if (!histogram) return send();
|
|
6807
|
+
const start = performance.now();
|
|
6808
|
+
const record = (statusCode) => histogram.record(performance.now() - start, {
|
|
6809
|
+
runner_kind: runnerKind,
|
|
6810
|
+
source: "daemon",
|
|
6811
|
+
status_code: statusCode
|
|
6812
|
+
});
|
|
6813
|
+
try {
|
|
6814
|
+
const res = await send();
|
|
6815
|
+
record(res.status);
|
|
6816
|
+
return res;
|
|
6817
|
+
} catch (err) {
|
|
6818
|
+
record(0);
|
|
6819
|
+
throw err;
|
|
6820
|
+
}
|
|
6821
|
+
};
|
|
6822
|
+
}
|
|
6823
|
+
|
|
6824
|
+
// ../sandbox/server/provider/freestyle.ts
|
|
6825
|
+
var LOG_LABEL2 = "FreestyleSandboxProvider";
|
|
6826
|
+
var DAEMON_PORT = 9e3;
|
|
6827
|
+
var WORKDIR = "/app";
|
|
6828
|
+
var DEFAULT_DEV_PORT2 = 3e3;
|
|
6829
|
+
var CONTAINER = "sandbox";
|
|
6830
|
+
var TOKEN_KEY = "studio-daemon-token";
|
|
6831
|
+
var ORG_KEY = "studio-org-id";
|
|
6832
|
+
var USER_KEY = "studio-user-id";
|
|
6833
|
+
var DAEMON_BOOT_TIMEOUT_MS = 12e4;
|
|
6834
|
+
var HEALTH_POLL_MS = 500;
|
|
6835
|
+
var HEALTH_PROBE_TIMEOUT_MS2 = 5e3;
|
|
6836
|
+
var IMAGE_PULL_TIMEOUT_MS = 3e5;
|
|
6837
|
+
var LOOKUP_TTL_MS = 5 * 6e4;
|
|
6838
|
+
var MISS_TTL_MS = 1e4;
|
|
6839
|
+
var LOOKUP_MAX = 5e3;
|
|
6840
|
+
var WATCH_TIMEOUT_MS = 5 * 6e4;
|
|
6841
|
+
var FreestyleSandboxProvider = class {
|
|
6842
|
+
freestyle;
|
|
6843
|
+
image;
|
|
6844
|
+
idleTimeoutSeconds;
|
|
6845
|
+
domainSuffix;
|
|
6846
|
+
timeDaemonRequest;
|
|
6847
|
+
inflight = new Inflight();
|
|
6848
|
+
snapshotInflight = new Inflight();
|
|
6849
|
+
lookups = /* @__PURE__ */ new Map();
|
|
6850
|
+
releaseTimers = /* @__PURE__ */ new Map();
|
|
6851
|
+
constructor(opts) {
|
|
6852
|
+
this.freestyle = new Freestyle({ apiKey: opts.apiKey });
|
|
6853
|
+
this.image = opts.image ?? `ghcr.io/decocms/studio/studio-sandbox-go:${package_default.version}`;
|
|
6854
|
+
this.idleTimeoutSeconds = opts.idleTimeoutSeconds ?? 15 * 60;
|
|
6855
|
+
this.domainSuffix = opts.domainSuffix ?? "style.dev";
|
|
6856
|
+
this.timeDaemonRequest = daemonProxyTimer(opts.meter);
|
|
6857
|
+
}
|
|
6858
|
+
/** Whether this handle is a Freestyle sandbox. Cached; see `MISS_TTL_MS`. */
|
|
6859
|
+
async owns(handle) {
|
|
6860
|
+
return await this.find(handle) !== null;
|
|
6861
|
+
}
|
|
6862
|
+
// ---- Lookup ---------------------------------------------------------------
|
|
6863
|
+
daemonUrl(handle) {
|
|
6864
|
+
return `https://${handle}.${this.domainSuffix}`;
|
|
6865
|
+
}
|
|
6866
|
+
async find(handle, opts = {}) {
|
|
6867
|
+
const hit = this.lookups.get(handle);
|
|
6868
|
+
if (!opts.refresh && hit && Date.now() - hit.at < (hit.found ? LOOKUP_TTL_MS : MISS_TTL_MS)) {
|
|
6869
|
+
return hit.found;
|
|
6870
|
+
}
|
|
6871
|
+
let found = null;
|
|
6872
|
+
try {
|
|
6873
|
+
const data = await this.freestyle.vms.get(handle);
|
|
6874
|
+
const token = data.metadata[TOKEN_KEY];
|
|
6875
|
+
if (token) {
|
|
6876
|
+
found = {
|
|
6877
|
+
vm: this.freestyle.vms.ref(data.id),
|
|
6878
|
+
daemon: { url: this.daemonUrl(handle), token }
|
|
6879
|
+
};
|
|
6880
|
+
}
|
|
6881
|
+
} catch (err) {
|
|
6882
|
+
if (!(err instanceof FreestyleApiError && err.status === 404)) throw err;
|
|
6883
|
+
}
|
|
6884
|
+
this.remember(handle, found);
|
|
6885
|
+
return found;
|
|
6886
|
+
}
|
|
6887
|
+
remember(handle, found) {
|
|
6888
|
+
this.lookups.delete(handle);
|
|
6889
|
+
if (this.lookups.size >= LOOKUP_MAX) {
|
|
6890
|
+
const oldest = this.lookups.keys().next();
|
|
6891
|
+
if (!oldest.done) this.lookups.delete(oldest.value);
|
|
6892
|
+
}
|
|
6893
|
+
this.lookups.set(handle, { found, at: Date.now() });
|
|
6894
|
+
}
|
|
6895
|
+
// ---- Base snapshot --------------------------------------------------------
|
|
6896
|
+
/**
|
|
6897
|
+
* A snapshot of a VM with the image already pulled, so a sandbox boots in
|
|
6898
|
+
* seconds instead of pulling ~2GB. One per image; built on first use.
|
|
6899
|
+
*/
|
|
6900
|
+
baseSnapshot() {
|
|
6901
|
+
const image = this.image;
|
|
6902
|
+
const slug = `studio-sandbox-${createHash3("sha256").update(image).digest("hex").slice(0, 16)}`;
|
|
6903
|
+
return this.snapshotInflight.run(slug, async () => {
|
|
6904
|
+
if (await this.snapshotExists(slug)) return slug;
|
|
6905
|
+
console.log(`[${LOG_LABEL2}] building base snapshot ${slug} for ${image}`);
|
|
6906
|
+
const { vm } = await this.freestyle.vms.create({
|
|
6907
|
+
ttlSeconds: 1800,
|
|
6908
|
+
firewall: {
|
|
6909
|
+
rules: [
|
|
6910
|
+
{ action: "allow", source: {}, destination: { public: true } }
|
|
6911
|
+
]
|
|
6912
|
+
}
|
|
6913
|
+
});
|
|
6914
|
+
try {
|
|
6915
|
+
await this.run(vm, `docker pull -q ${shellQuote(image)}`, {
|
|
6916
|
+
timeoutMs: IMAGE_PULL_TIMEOUT_MS
|
|
6917
|
+
});
|
|
6918
|
+
await vm.snapshot({ slug });
|
|
6919
|
+
} catch (err) {
|
|
6920
|
+
if (!await this.snapshotExists(slug)) throw err;
|
|
6921
|
+
} finally {
|
|
6922
|
+
await vm.delete().catch(() => {
|
|
6923
|
+
});
|
|
6924
|
+
}
|
|
6925
|
+
return slug;
|
|
6926
|
+
});
|
|
6927
|
+
}
|
|
6928
|
+
async snapshotExists(slug) {
|
|
6929
|
+
try {
|
|
6930
|
+
await this.freestyle.vms.snapshots.get(slug);
|
|
6931
|
+
return true;
|
|
6932
|
+
} catch (err) {
|
|
6933
|
+
if (err instanceof FreestyleApiError && err.status === 404) return false;
|
|
6934
|
+
throw err;
|
|
6935
|
+
}
|
|
6936
|
+
}
|
|
6937
|
+
async run(vm, command, opts = {}) {
|
|
6938
|
+
const res = await vm.exec({ command, ...opts });
|
|
6939
|
+
if (res.statusCode !== 0) {
|
|
6940
|
+
throw new Error(
|
|
6941
|
+
`${command.split(" ", 2).join(" ")} failed (${res.statusCode ?? "timeout"}): ${(res.stderr || res.stdout || "").slice(-500)}`
|
|
6942
|
+
);
|
|
6943
|
+
}
|
|
6944
|
+
return res.stdout ?? "";
|
|
6945
|
+
}
|
|
6946
|
+
// ---- Lifecycle ------------------------------------------------------------
|
|
6947
|
+
async ensure(id2, opts = {}) {
|
|
6948
|
+
const handle = computeHandle(id2);
|
|
6949
|
+
if (handle.startsWith("s-")) {
|
|
6950
|
+
throw new Error(
|
|
6951
|
+
`ensure: projectRef has no slug source: ${id2.projectRef}`
|
|
6952
|
+
);
|
|
6953
|
+
}
|
|
6954
|
+
return this.inflight.run(handle, async () => {
|
|
6955
|
+
this.cancelRelease(handle);
|
|
6956
|
+
const existing = await this.find(handle, { refresh: true });
|
|
6957
|
+
if (existing) {
|
|
6958
|
+
await this.resume(existing, opts);
|
|
6959
|
+
} else {
|
|
6960
|
+
await this.create(handle, opts);
|
|
6961
|
+
}
|
|
6962
|
+
return {
|
|
6963
|
+
handle,
|
|
6964
|
+
workdir: WORKDIR,
|
|
6965
|
+
previewUrl: this.daemonUrl(handle),
|
|
6966
|
+
warmPoolAdopted: false,
|
|
6967
|
+
provider: "freestyle"
|
|
6968
|
+
};
|
|
6969
|
+
});
|
|
6970
|
+
}
|
|
6971
|
+
async resume(found, opts) {
|
|
6972
|
+
const { state } = await found.vm.data();
|
|
6973
|
+
if (state !== "running") await found.vm.start();
|
|
6974
|
+
await this.waitForDaemon(found.daemon.url);
|
|
6975
|
+
const patch = gitCredentialRefreshPatch(opts);
|
|
6976
|
+
if (patch) {
|
|
6977
|
+
await postConfig(found.daemon.url, found.daemon.token, patch).catch(
|
|
6978
|
+
(err) => console.warn(
|
|
6979
|
+
`[${LOG_LABEL2}] git credential refresh failed: ${errMsg2(err)}`
|
|
6980
|
+
)
|
|
6981
|
+
);
|
|
6982
|
+
}
|
|
6983
|
+
}
|
|
6984
|
+
async create(handle, opts) {
|
|
6985
|
+
const snapshotId = await this.baseSnapshot();
|
|
6986
|
+
const token = randomBytes2(24).toString("hex");
|
|
6987
|
+
const { vm } = await this.freestyle.vms.create({
|
|
6988
|
+
snapshotId,
|
|
6989
|
+
slug: handle,
|
|
6990
|
+
idleTimeoutSeconds: this.idleTimeoutSeconds,
|
|
6991
|
+
metadata: {
|
|
6992
|
+
[TOKEN_KEY]: token,
|
|
6993
|
+
...opts.tenant ? { [ORG_KEY]: opts.tenant.orgId, [USER_KEY]: opts.tenant.userId } : {}
|
|
6994
|
+
},
|
|
6995
|
+
firewall: {
|
|
6996
|
+
rules: [{ action: "allow", source: {}, destination: { public: true } }]
|
|
6997
|
+
},
|
|
6998
|
+
tls: {
|
|
6999
|
+
rules: [
|
|
7000
|
+
{
|
|
7001
|
+
action: "allow",
|
|
7002
|
+
domain: `${handle}.${this.domainSuffix}`,
|
|
7003
|
+
source: { public: true },
|
|
7004
|
+
destination: { port: DAEMON_PORT }
|
|
7005
|
+
}
|
|
7006
|
+
]
|
|
7007
|
+
}
|
|
7008
|
+
});
|
|
7009
|
+
const daemon = { url: this.daemonUrl(handle), token };
|
|
7010
|
+
try {
|
|
7011
|
+
const env = {
|
|
7012
|
+
...opts.env,
|
|
7013
|
+
DAEMON_TOKEN: token,
|
|
7014
|
+
DAEMON_BOOT_ID: crypto.randomUUID(),
|
|
7015
|
+
APP_ROOT: WORKDIR,
|
|
7016
|
+
PROXY_PORT: String(DAEMON_PORT)
|
|
7017
|
+
};
|
|
7018
|
+
const envFlags = Object.keys(env).map((k) => `-e ${shellQuote(k)}`).join(" ");
|
|
7019
|
+
await this.run(
|
|
7020
|
+
vm,
|
|
7021
|
+
`docker run -d --name ${CONTAINER} --restart=always -p ${DAEMON_PORT}:${DAEMON_PORT} ${envFlags} ${shellQuote(this.image)}`,
|
|
7022
|
+
{ env, timeoutMs: IMAGE_PULL_TIMEOUT_MS }
|
|
7023
|
+
);
|
|
7024
|
+
await this.waitForDaemon(daemon.url);
|
|
7025
|
+
await postConfig(daemon.url, token, this.configPayload(opts) ?? {});
|
|
7026
|
+
if (opts.orgFsConfigJson) {
|
|
7027
|
+
await postOrgFsConfig(daemon.url, token, opts.orgFsConfigJson).catch(
|
|
7028
|
+
(err) => console.warn(`[${LOG_LABEL2}] org-fs relay failed`, err)
|
|
7029
|
+
);
|
|
7030
|
+
}
|
|
7031
|
+
} catch (err) {
|
|
7032
|
+
await vm.delete().catch(() => {
|
|
7033
|
+
});
|
|
7034
|
+
this.lookups.delete(handle);
|
|
7035
|
+
throw err;
|
|
7036
|
+
}
|
|
7037
|
+
this.remember(handle, { vm, daemon });
|
|
7038
|
+
}
|
|
7039
|
+
configPayload(opts) {
|
|
7040
|
+
return buildConfigPayload({
|
|
7041
|
+
runtime: opts.workload?.runtime ?? "node",
|
|
7042
|
+
packageManager: opts.workload?.packageManager ? {
|
|
7043
|
+
name: opts.workload.packageManager,
|
|
7044
|
+
...opts.workload.packageManagerPath ? { path: opts.workload.packageManagerPath } : {}
|
|
7045
|
+
} : null,
|
|
7046
|
+
repo: opts.repo ?? null,
|
|
7047
|
+
extraRepos: opts.extraRepos ?? [],
|
|
7048
|
+
port: opts.workload?.devPort ?? DEFAULT_DEV_PORT2,
|
|
7049
|
+
tenant: opts.tenant,
|
|
7050
|
+
cloneOnly: opts.cloneOnly === true
|
|
7051
|
+
});
|
|
7052
|
+
}
|
|
7053
|
+
async waitForDaemon(url) {
|
|
7054
|
+
const deadline = Date.now() + DAEMON_BOOT_TIMEOUT_MS;
|
|
7055
|
+
while (Date.now() < deadline) {
|
|
7056
|
+
if (await daemonAnswers(url)) return;
|
|
7057
|
+
await delay(HEALTH_POLL_MS);
|
|
7058
|
+
}
|
|
7059
|
+
throw new Error(
|
|
7060
|
+
`sandbox daemon at ${url} did not answer /health within ${DAEMON_BOOT_TIMEOUT_MS / 1e3}s`
|
|
7061
|
+
);
|
|
7062
|
+
}
|
|
7063
|
+
async delete(handle) {
|
|
7064
|
+
this.cancelRelease(handle);
|
|
7065
|
+
const found = await this.find(handle, { refresh: true });
|
|
7066
|
+
this.lookups.delete(handle);
|
|
7067
|
+
if (!found) return;
|
|
7068
|
+
await found.vm.exec({ command: `docker stop -t 30 ${CONTAINER}`, timeoutMs: 45e3 }).catch(() => {
|
|
7069
|
+
});
|
|
7070
|
+
await found.vm.delete();
|
|
7071
|
+
}
|
|
7072
|
+
async lastTermination(_handle) {
|
|
7073
|
+
return null;
|
|
7074
|
+
}
|
|
7075
|
+
/** A paused VM is alive: the next request resumes it. */
|
|
7076
|
+
async alive(handle) {
|
|
7077
|
+
return await this.find(handle, { refresh: true }) !== null;
|
|
7078
|
+
}
|
|
7079
|
+
/** `ensure` only returns once the daemon answers, so this only has to see that. */
|
|
7080
|
+
async *watchClaimLifecycle(handle, signal) {
|
|
7081
|
+
const since2 = Date.now();
|
|
7082
|
+
yield { kind: "claiming", since: since2 };
|
|
7083
|
+
let announced = false;
|
|
7084
|
+
while (!signal?.aborted) {
|
|
7085
|
+
const found = await this.find(handle, { refresh: true }).catch(
|
|
7086
|
+
() => null
|
|
7087
|
+
);
|
|
7088
|
+
if (found) {
|
|
7089
|
+
if (!announced) {
|
|
7090
|
+
announced = true;
|
|
7091
|
+
yield { kind: "warming-daemon", since: Date.now() };
|
|
7092
|
+
}
|
|
7093
|
+
if (await daemonAnswers(found.daemon.url)) {
|
|
7094
|
+
yield { kind: "ready" };
|
|
7095
|
+
return;
|
|
7096
|
+
}
|
|
7097
|
+
}
|
|
7098
|
+
if (Date.now() - since2 > WATCH_TIMEOUT_MS) {
|
|
7099
|
+
yield {
|
|
7100
|
+
kind: "failed",
|
|
7101
|
+
reason: found ? "crash-loop-backoff" : "claim-never-created",
|
|
7102
|
+
message: found ? "the sandbox VM exists but its daemon never answered" : "no sandbox VM was created"
|
|
7103
|
+
};
|
|
7104
|
+
return;
|
|
7105
|
+
}
|
|
7106
|
+
await delay(1e3);
|
|
7107
|
+
}
|
|
7108
|
+
}
|
|
7109
|
+
async hasSchedulableCapacity() {
|
|
7110
|
+
return true;
|
|
7111
|
+
}
|
|
7112
|
+
async getPreviewUrl(handle) {
|
|
7113
|
+
return await this.find(handle) ? this.daemonUrl(handle) : null;
|
|
7114
|
+
}
|
|
7115
|
+
async proxyDaemonRequest(handle, path, init) {
|
|
7116
|
+
const found = await this.find(handle);
|
|
7117
|
+
if (!found) {
|
|
7118
|
+
return Response.json({ error: "sandbox not found" }, { status: 404 });
|
|
7119
|
+
}
|
|
7120
|
+
const reread = async () => (await this.find(handle, { refresh: true }))?.daemon ?? null;
|
|
7121
|
+
return this.timeDaemonRequest(
|
|
7122
|
+
RUNNER_KIND.freestyle,
|
|
7123
|
+
() => proxyDaemonWithRetry(found.daemon, path, init, {
|
|
7124
|
+
unauthorized: async () => {
|
|
7125
|
+
const fresh = await reread();
|
|
7126
|
+
return fresh && fresh.token !== found.daemon.token ? fresh : null;
|
|
7127
|
+
},
|
|
7128
|
+
unreachable: reread
|
|
7129
|
+
})
|
|
7130
|
+
);
|
|
7131
|
+
}
|
|
7132
|
+
/** The daemon's public address and bearer, for a host serving `sandbox-api`. */
|
|
7133
|
+
async daemonEndpoint(handle) {
|
|
7134
|
+
return (await this.find(handle))?.daemon ?? null;
|
|
7135
|
+
}
|
|
7136
|
+
async adoptLiveClaim(_id, handle) {
|
|
7137
|
+
return await this.find(handle, { refresh: true }) !== null;
|
|
7138
|
+
}
|
|
7139
|
+
async resolvePreviewUpstreamUrl(handle) {
|
|
7140
|
+
return (await this.find(handle))?.daemon.url ?? null;
|
|
7141
|
+
}
|
|
7142
|
+
proxyPreviewRequest(handle, request) {
|
|
7143
|
+
return proxyPreview(
|
|
7144
|
+
request,
|
|
7145
|
+
{
|
|
7146
|
+
base: () => this.resolvePreviewUpstreamUrl(handle).catch(() => null),
|
|
7147
|
+
invalidate: () => this.lookups.delete(handle),
|
|
7148
|
+
retryBase: () => this.find(handle, { refresh: true }).then((f) => f?.daemon.url ?? null).catch(() => null)
|
|
7149
|
+
},
|
|
7150
|
+
LOG_LABEL2
|
|
7151
|
+
);
|
|
7152
|
+
}
|
|
7153
|
+
async markTenantPoolsDirty(_repoUrl, _ref) {
|
|
7154
|
+
return [];
|
|
7155
|
+
}
|
|
7156
|
+
/**
|
|
7157
|
+
* Pause after `graceMs`. Pausing keeps memory and disk, so this costs the
|
|
7158
|
+
* sandbox nothing but a resume.
|
|
7159
|
+
*
|
|
7160
|
+
* ponytail: in-process timer, lost on restart; the idle timeout pauses the
|
|
7161
|
+
* VM anyway, only later.
|
|
7162
|
+
*/
|
|
7163
|
+
async releaseAfter(handle, graceMs) {
|
|
7164
|
+
this.cancelRelease(handle);
|
|
7165
|
+
const timer = setTimeout(() => {
|
|
7166
|
+
this.releaseTimers.delete(handle);
|
|
7167
|
+
void this.find(handle).then((f) => f?.vm.pause()).catch(
|
|
7168
|
+
(err) => console.warn(`[${LOG_LABEL2}] pause ${handle} failed: ${errMsg2(err)}`)
|
|
7169
|
+
);
|
|
7170
|
+
}, graceMs);
|
|
7171
|
+
timer.unref?.();
|
|
7172
|
+
this.releaseTimers.set(handle, timer);
|
|
7173
|
+
}
|
|
7174
|
+
/** The VM's idle timeout already counts every request as activity. */
|
|
7175
|
+
async renewTtl(handle) {
|
|
7176
|
+
this.cancelRelease(handle);
|
|
7177
|
+
}
|
|
7178
|
+
cancelRelease(handle) {
|
|
7179
|
+
const timer = this.releaseTimers.get(handle);
|
|
7180
|
+
if (timer) clearTimeout(timer);
|
|
7181
|
+
this.releaseTimers.delete(handle);
|
|
7182
|
+
}
|
|
7183
|
+
close() {
|
|
7184
|
+
for (const timer of this.releaseTimers.values()) clearTimeout(timer);
|
|
7185
|
+
this.releaseTimers.clear();
|
|
7186
|
+
this.lookups.clear();
|
|
7187
|
+
}
|
|
7188
|
+
};
|
|
7189
|
+
async function daemonAnswers(url) {
|
|
7190
|
+
try {
|
|
7191
|
+
const res = await fetch(`${url}/health`, {
|
|
7192
|
+
signal: AbortSignal.timeout(HEALTH_PROBE_TIMEOUT_MS2)
|
|
7193
|
+
});
|
|
7194
|
+
await res.body?.cancel();
|
|
7195
|
+
return res.ok;
|
|
7196
|
+
} catch {
|
|
7197
|
+
return false;
|
|
7198
|
+
}
|
|
7199
|
+
}
|
|
7200
|
+
function shellQuote(s) {
|
|
7201
|
+
return `'${s.replaceAll("'", `'\\''`)}'`;
|
|
7202
|
+
}
|
|
7203
|
+
function errMsg2(err) {
|
|
7204
|
+
return err instanceof Error ? err.message : String(err);
|
|
7205
|
+
}
|
|
7206
|
+
|
|
7207
|
+
// ../sandbox/server/provider/router.ts
|
|
7208
|
+
import { createHash as createHash4 } from "crypto";
|
|
7209
|
+
|
|
7210
|
+
// ../sandbox/server/provider/shared/provider-tag.ts
|
|
7211
|
+
var KEY = "sandboxProvider";
|
|
7212
|
+
function tagSandboxProvider(err, kind) {
|
|
7213
|
+
if (err instanceof Error) Object.assign(err, { [KEY]: kind });
|
|
7214
|
+
return err;
|
|
7215
|
+
}
|
|
7216
|
+
function sandboxProviderOfError(err) {
|
|
7217
|
+
for (let e = err, depth = 0; e instanceof Error && depth < 5; depth++) {
|
|
7218
|
+
const kind = e[KEY];
|
|
7219
|
+
if (kind === "kubernetes" || kind === "freestyle") return kind;
|
|
7220
|
+
e = e.cause;
|
|
7221
|
+
}
|
|
7222
|
+
return void 0;
|
|
7223
|
+
}
|
|
7224
|
+
|
|
7225
|
+
// ../sandbox/server/provider/router.ts
|
|
7226
|
+
function handleShare(handle) {
|
|
7227
|
+
const hex = createHash4("sha256").update(handle).digest("hex").slice(0, 8);
|
|
7228
|
+
return Number.parseInt(hex, 16) / 2 ** 32;
|
|
7229
|
+
}
|
|
7230
|
+
var PICKED_MAX = 5e3;
|
|
7231
|
+
var SandboxProviderRouter = class {
|
|
7232
|
+
constructor(providers, opts = {}) {
|
|
7233
|
+
this.providers = providers;
|
|
7234
|
+
if (!providers.kubernetes && !providers.freestyle) {
|
|
7235
|
+
throw new Error("SandboxProviderRouter needs at least one provider");
|
|
7236
|
+
}
|
|
7237
|
+
this.freestyleShare = Math.min(1, Math.max(0, opts.freestyleShare ?? 0));
|
|
7238
|
+
}
|
|
7239
|
+
providers;
|
|
7240
|
+
picked = /* @__PURE__ */ new Map();
|
|
7241
|
+
freestyleShare;
|
|
7242
|
+
get(kind) {
|
|
7243
|
+
const provider = this.providers[kind];
|
|
7244
|
+
if (!provider)
|
|
7245
|
+
throw new Error(`sandbox provider ${kind} is not configured`);
|
|
7246
|
+
return provider;
|
|
7247
|
+
}
|
|
7248
|
+
remember(handle, kind) {
|
|
7249
|
+
this.picked.delete(handle);
|
|
7250
|
+
if (this.picked.size >= PICKED_MAX) {
|
|
7251
|
+
const oldest = this.picked.keys().next();
|
|
7252
|
+
if (!oldest.done) this.picked.delete(oldest.value);
|
|
7253
|
+
}
|
|
7254
|
+
this.picked.set(handle, kind);
|
|
7255
|
+
}
|
|
7256
|
+
/** Who serves an existing handle. Kubernetes is the fallback: it has no cheap ownership check. */
|
|
7257
|
+
async providerOf(handle) {
|
|
7258
|
+
const { kubernetes, freestyle } = this.providers;
|
|
7259
|
+
if (!freestyle) return "kubernetes";
|
|
7260
|
+
if (!kubernetes) return "freestyle";
|
|
7261
|
+
const picked = this.picked.get(handle);
|
|
7262
|
+
if (picked) return picked;
|
|
7263
|
+
return await freestyle.owns(handle) ? "freestyle" : "kubernetes";
|
|
7264
|
+
}
|
|
7265
|
+
async owner(handle) {
|
|
7266
|
+
return this.get(await this.providerOf(handle));
|
|
7267
|
+
}
|
|
7268
|
+
/** Where `ensure` puts this sandbox. */
|
|
7269
|
+
async place(handle, opts) {
|
|
7270
|
+
const { kubernetes, freestyle } = this.providers;
|
|
7271
|
+
const image = opts.sandboxImage ?? "default";
|
|
7272
|
+
if (image !== "default") {
|
|
7273
|
+
if (!kubernetes) {
|
|
7274
|
+
throw new Error(`sandbox image ${image} needs the kubernetes provider`);
|
|
7275
|
+
}
|
|
7276
|
+
return "kubernetes";
|
|
7277
|
+
}
|
|
7278
|
+
if (!kubernetes) return "freestyle";
|
|
7279
|
+
if (!freestyle) return "kubernetes";
|
|
7280
|
+
if (await freestyle.owns(handle)) return "freestyle";
|
|
7281
|
+
if (await kubernetes.alive(handle).catch(() => false)) return "kubernetes";
|
|
7282
|
+
if (opts.provider) return opts.provider;
|
|
7283
|
+
if (handleShare(handle) < this.freestyleShare) return "freestyle";
|
|
7284
|
+
const roomy = await kubernetes.hasSchedulableCapacity().catch(() => false);
|
|
7285
|
+
return roomy ? "kubernetes" : "freestyle";
|
|
7286
|
+
}
|
|
7287
|
+
async ensure(id2, opts = {}) {
|
|
7288
|
+
const { provider: _requested, ...rest } = opts;
|
|
7289
|
+
const handle = computeHandle(id2);
|
|
7290
|
+
const kind = await this.place(handle, opts);
|
|
7291
|
+
this.remember(handle, kind);
|
|
7292
|
+
try {
|
|
7293
|
+
return await this.get(kind).ensure(id2, rest);
|
|
7294
|
+
} catch (err) {
|
|
7295
|
+
throw tagSandboxProvider(err, kind);
|
|
7296
|
+
}
|
|
7297
|
+
}
|
|
7298
|
+
async delete(handle) {
|
|
7299
|
+
await (await this.owner(handle)).delete(handle);
|
|
7300
|
+
this.picked.delete(handle);
|
|
7301
|
+
}
|
|
7302
|
+
async lastTermination(handle) {
|
|
7303
|
+
return (await this.owner(handle)).lastTermination(handle);
|
|
7304
|
+
}
|
|
7305
|
+
async alive(handle) {
|
|
7306
|
+
return (await this.owner(handle)).alive(handle);
|
|
7307
|
+
}
|
|
7308
|
+
async *watchClaimLifecycle(handle, signal) {
|
|
7309
|
+
yield* (await this.owner(handle)).watchClaimLifecycle(handle, signal);
|
|
7310
|
+
}
|
|
7311
|
+
/** Room anywhere: an ensure without room on Kubernetes lands on Freestyle. */
|
|
7312
|
+
async hasSchedulableCapacity() {
|
|
7313
|
+
const { kubernetes, freestyle } = this.providers;
|
|
7314
|
+
if (!kubernetes || freestyle) return true;
|
|
7315
|
+
return kubernetes.hasSchedulableCapacity();
|
|
7316
|
+
}
|
|
7317
|
+
async getPreviewUrl(handle) {
|
|
7318
|
+
return (await this.owner(handle)).getPreviewUrl(handle);
|
|
7319
|
+
}
|
|
7320
|
+
async proxyDaemonRequest(handle, path, init) {
|
|
7321
|
+
return (await this.owner(handle)).proxyDaemonRequest(handle, path, init);
|
|
7322
|
+
}
|
|
7323
|
+
async adoptLiveClaim(id2, handle) {
|
|
7324
|
+
return (await this.owner(handle)).adoptLiveClaim(id2, handle);
|
|
7325
|
+
}
|
|
7326
|
+
async resolvePreviewUpstreamUrl(handle) {
|
|
7327
|
+
return (await this.owner(handle)).resolvePreviewUpstreamUrl(handle);
|
|
7328
|
+
}
|
|
7329
|
+
async proxyPreviewRequest(handle, request) {
|
|
7330
|
+
return (await this.owner(handle)).proxyPreviewRequest(handle, request);
|
|
7331
|
+
}
|
|
7332
|
+
async markTenantPoolsDirty(repoUrl, ref) {
|
|
7333
|
+
return await this.providers.kubernetes?.markTenantPoolsDirty(repoUrl, ref) ?? [];
|
|
7334
|
+
}
|
|
7335
|
+
async releaseAfter(handle, graceMs) {
|
|
7336
|
+
await (await this.owner(handle)).releaseAfter(handle, graceMs);
|
|
7337
|
+
}
|
|
7338
|
+
async renewTtl(handle) {
|
|
7339
|
+
await (await this.owner(handle)).renewTtl(handle);
|
|
7340
|
+
}
|
|
7341
|
+
async daemonEndpoint(handle) {
|
|
7342
|
+
const owner = this.providers[await this.providerOf(handle)];
|
|
7343
|
+
if (!owner?.daemonEndpoint) {
|
|
7344
|
+
throw new Error("sandbox provider has no daemon endpoint to serve");
|
|
7345
|
+
}
|
|
7346
|
+
return owner.daemonEndpoint(handle);
|
|
7347
|
+
}
|
|
7348
|
+
/** Only Kubernetes sandboxes take pushed credentials. */
|
|
7349
|
+
listSandboxes() {
|
|
7350
|
+
return this.providers.kubernetes?.listSandboxes?.() ?? [];
|
|
7351
|
+
}
|
|
7352
|
+
listTenantPools() {
|
|
7353
|
+
return this.providers.kubernetes?.listTenantPools?.() ?? [];
|
|
7354
|
+
}
|
|
7355
|
+
close() {
|
|
7356
|
+
this.providers.kubernetes?.close();
|
|
7357
|
+
this.providers.freestyle?.close();
|
|
7358
|
+
this.picked.clear();
|
|
7359
|
+
}
|
|
7360
|
+
};
|
|
7361
|
+
|
|
7362
|
+
// ../sandbox/server/provider/sandbox-api.ts
|
|
7363
|
+
import { z as z4 } from "zod";
|
|
7364
|
+
var SANDBOX_TOOLS = {
|
|
7365
|
+
ensure: "SANDBOX_ENSURE",
|
|
7366
|
+
status: "SANDBOX_STATUS",
|
|
7367
|
+
delete: "SANDBOX_DELETE",
|
|
7368
|
+
lifetime: "SANDBOX_LIFETIME",
|
|
7369
|
+
capacity: "SANDBOX_CAPACITY",
|
|
7370
|
+
tenantPoolsPush: "SANDBOX_TENANT_POOLS_PUSH",
|
|
7371
|
+
credentialsPush: "SANDBOX_CREDENTIALS_PUSH",
|
|
7372
|
+
list: "SANDBOX_LIST"
|
|
7373
|
+
};
|
|
7374
|
+
var SANDBOX_WATCH_PATH = "/api/sandbox/watch";
|
|
7375
|
+
var SANDBOX_WATCH_KEEPALIVE_MS = 15e3;
|
|
7376
|
+
var id = z4.string().min(1).max(512);
|
|
7377
|
+
var CLONE_URL_MAX = 4096;
|
|
7378
|
+
var ORG_FS_CONFIG_MAX = 65536;
|
|
7379
|
+
var expiresAt = z4.number().int().positive();
|
|
7380
|
+
var sandboxIdSchema = z4.object({
|
|
7381
|
+
userId: id,
|
|
7382
|
+
projectRef: id
|
|
7383
|
+
});
|
|
7384
|
+
var tenantSchema = z4.object({
|
|
7385
|
+
orgId: id,
|
|
7386
|
+
userId: id,
|
|
7387
|
+
orgSlug: z4.string().optional(),
|
|
7388
|
+
orgName: z4.string().optional(),
|
|
7389
|
+
userEmail: z4.string().optional(),
|
|
7390
|
+
userName: z4.string().optional()
|
|
7391
|
+
});
|
|
7392
|
+
var repoSchema = z4.object({
|
|
7393
|
+
cloneUrl: z4.string().min(1).max(CLONE_URL_MAX),
|
|
7394
|
+
connectionId: z4.string().optional(),
|
|
7395
|
+
repositoryId: z4.string().optional(),
|
|
7396
|
+
userName: z4.string(),
|
|
7397
|
+
userEmail: z4.string(),
|
|
7398
|
+
branch: z4.string().optional(),
|
|
7399
|
+
displayName: z4.string().optional(),
|
|
7400
|
+
submoduleCredentials: z4.array(z4.object({ host: z4.string(), token: z4.string() })).optional(),
|
|
7401
|
+
directoryName: z4.string().optional(),
|
|
7402
|
+
credentialExpiresAt: expiresAt.optional()
|
|
7403
|
+
});
|
|
7404
|
+
var providerKindSchema = z4.enum(["kubernetes", "freestyle"]);
|
|
7405
|
+
var ensureOptionsSchema = z4.object({
|
|
7406
|
+
provider: providerKindSchema.optional(),
|
|
7407
|
+
purpose: z4.enum(["interactive", "harness-run"]).optional(),
|
|
7408
|
+
sandboxImage: SandboxImageSchema.optional(),
|
|
7409
|
+
branch: z4.string().optional(),
|
|
7410
|
+
repo: repoSchema.optional(),
|
|
7411
|
+
extraRepos: z4.array(repoSchema).optional(),
|
|
7412
|
+
workload: z4.object({
|
|
7413
|
+
runtime: z4.enum(["node", "bun", "deno"]),
|
|
7414
|
+
packageManager: z4.enum(["npm", "pnpm", "yarn", "bun", "deno"]),
|
|
7415
|
+
devPort: z4.number().int().optional(),
|
|
7416
|
+
packageManagerPath: z4.string().optional()
|
|
7417
|
+
}).optional(),
|
|
7418
|
+
cloneOnly: z4.boolean().optional(),
|
|
7419
|
+
env: z4.record(z4.string(), z4.string()).optional(),
|
|
7420
|
+
tenant: tenantSchema.optional(),
|
|
7421
|
+
orgFsConfigJson: z4.string().max(ORG_FS_CONFIG_MAX).optional()
|
|
7422
|
+
});
|
|
7423
|
+
var daemonSchema = z4.object({
|
|
7424
|
+
url: z4.url({ protocol: /^https?$/ }),
|
|
7425
|
+
token: z4.string().min(1)
|
|
7426
|
+
});
|
|
7427
|
+
var ensureInputSchema = z4.object({
|
|
7428
|
+
id: sandboxIdSchema,
|
|
7429
|
+
opts: ensureOptionsSchema,
|
|
7430
|
+
/**
|
|
7431
|
+
* Until when `opts.orgFsConfigJson` stays valid, at least; unset keeps it
|
|
7432
|
+
* out of the host's store. Clone credentials carry their own
|
|
7433
|
+
* `credentialExpiresAt`.
|
|
7434
|
+
*/
|
|
7435
|
+
credentialsValidUntil: z4.object({ orgFsConfig: expiresAt.optional() }).optional()
|
|
7436
|
+
});
|
|
7437
|
+
var ensureOutputSchema = z4.object({
|
|
7438
|
+
handle: z4.string().min(1),
|
|
7439
|
+
workdir: z4.string().min(1),
|
|
7440
|
+
previewUrl: z4.string().nullable(),
|
|
7441
|
+
warmPoolAdopted: z4.boolean(),
|
|
7442
|
+
provider: providerKindSchema.default("kubernetes"),
|
|
7443
|
+
daemon: daemonSchema
|
|
7444
|
+
});
|
|
7445
|
+
var handleInputSchema = z4.object({ handle: z4.string().min(1) });
|
|
7446
|
+
var statusInputSchema = z4.object({
|
|
7447
|
+
handle: z4.string().min(1),
|
|
7448
|
+
/** Re-provision an evicted claim before answering. */
|
|
7449
|
+
resurrect: z4.boolean().optional()
|
|
7450
|
+
});
|
|
7451
|
+
var podTerminationSchema = z4.object({
|
|
7452
|
+
reason: z4.string(),
|
|
7453
|
+
oomKilled: z4.boolean(),
|
|
7454
|
+
exitCode: z4.number().int().optional(),
|
|
7455
|
+
memoryLimit: z4.string().optional(),
|
|
7456
|
+
evictionMessage: z4.string().optional()
|
|
7457
|
+
});
|
|
7458
|
+
var statusOutputSchema = z4.object({
|
|
7459
|
+
alive: z4.boolean(),
|
|
7460
|
+
previewUrl: z4.string().nullable(),
|
|
7461
|
+
daemon: daemonSchema.nullable(),
|
|
7462
|
+
lastTermination: podTerminationSchema.nullable(),
|
|
7463
|
+
provider: providerKindSchema.default("kubernetes")
|
|
7464
|
+
});
|
|
7465
|
+
var lifetimeInputSchema = z4.object({
|
|
7466
|
+
handle: z4.string().min(1),
|
|
7467
|
+
graceMs: z4.number().int().min(0).optional()
|
|
7468
|
+
});
|
|
7469
|
+
var emptySchema = z4.object({});
|
|
7470
|
+
var capacityOutputSchema = z4.object({ schedulable: z4.boolean() });
|
|
7471
|
+
var tenantPoolsPushInputSchema = z4.object({
|
|
7472
|
+
repoUrl: z4.string().min(1).max(CLONE_URL_MAX),
|
|
7473
|
+
ref: z4.string().min(1)
|
|
7474
|
+
});
|
|
7475
|
+
var tenantPoolsPushOutputSchema = z4.object({
|
|
7476
|
+
pools: z4.array(z4.string())
|
|
7477
|
+
});
|
|
7478
|
+
var toolErrorSchema = z4.object({
|
|
7479
|
+
code: z4.enum(["bootstrap-rejected", "internal"]),
|
|
7480
|
+
error: z4.string(),
|
|
7481
|
+
status: z4.number().int().optional(),
|
|
7482
|
+
/** The provider that failed, when the host knows it. */
|
|
7483
|
+
provider: providerKindSchema.optional()
|
|
7484
|
+
});
|
|
7485
|
+
var since = z4.number();
|
|
7486
|
+
var claimPhaseSchema = z4.discriminatedUnion(
|
|
7487
|
+
"kind",
|
|
7488
|
+
[
|
|
7489
|
+
z4.object({ kind: z4.literal("claiming"), since }),
|
|
7490
|
+
z4.object({
|
|
7491
|
+
kind: z4.literal("waiting-for-capacity"),
|
|
7492
|
+
since,
|
|
7493
|
+
message: z4.string().optional(),
|
|
7494
|
+
nodeClaim: z4.string().optional()
|
|
7495
|
+
}),
|
|
7496
|
+
z4.object({ kind: z4.literal("pulling-image"), since }),
|
|
7497
|
+
z4.object({ kind: z4.literal("starting-container"), since }),
|
|
7498
|
+
z4.object({ kind: z4.literal("warming-daemon"), since }),
|
|
7499
|
+
z4.object({ kind: z4.literal("ready") }),
|
|
7500
|
+
z4.object({
|
|
7501
|
+
kind: z4.literal("failed"),
|
|
7502
|
+
reason: z4.enum([
|
|
7503
|
+
"image-pull-backoff",
|
|
7504
|
+
"crash-loop-backoff",
|
|
7505
|
+
"scheduling-timeout",
|
|
7506
|
+
"claim-never-created",
|
|
7507
|
+
"reconciler-error",
|
|
7508
|
+
"unknown"
|
|
7509
|
+
]),
|
|
7510
|
+
message: z4.string()
|
|
7511
|
+
})
|
|
7512
|
+
]
|
|
7513
|
+
);
|
|
7514
|
+
var tenantIdsSchema = z4.object({ orgId: id, userId: id });
|
|
7515
|
+
var repoIdentitySchema = z4.union([
|
|
7516
|
+
z4.object({ repositoryId: id }).strict(),
|
|
7517
|
+
z4.object({ connectionId: id, repo: id }).strict()
|
|
7518
|
+
]);
|
|
7519
|
+
var CREDENTIALS_PUSH_MAX = 2e3;
|
|
7520
|
+
var pushedCloneUrl = z4.url({ protocol: /^https?$/ }).max(CLONE_URL_MAX);
|
|
7521
|
+
var credentialsPushInputSchema = z4.object({
|
|
7522
|
+
cloneUrls: z4.array(
|
|
7523
|
+
z4.object({
|
|
7524
|
+
tenant: tenantIdsSchema,
|
|
7525
|
+
repo: repoIdentitySchema,
|
|
7526
|
+
cloneUrl: pushedCloneUrl,
|
|
7527
|
+
expiresAt
|
|
7528
|
+
})
|
|
7529
|
+
).max(CREDENTIALS_PUSH_MAX),
|
|
7530
|
+
/** Clone URLs for tenant pool allocations, keyed by the pool's tenant and repo. */
|
|
7531
|
+
poolCloneUrls: z4.array(
|
|
7532
|
+
z4.object({
|
|
7533
|
+
tenant: id,
|
|
7534
|
+
repoUrl: z4.string().min(1).max(CLONE_URL_MAX),
|
|
7535
|
+
cloneUrl: pushedCloneUrl,
|
|
7536
|
+
expiresAt
|
|
7537
|
+
})
|
|
7538
|
+
).max(CREDENTIALS_PUSH_MAX).default([]),
|
|
7539
|
+
orgFsConfigs: z4.array(
|
|
7540
|
+
z4.object({
|
|
7541
|
+
tenant: tenantIdsSchema,
|
|
7542
|
+
orgFsConfigJson: z4.string().min(1).max(ORG_FS_CONFIG_MAX),
|
|
7543
|
+
expiresAt
|
|
7544
|
+
})
|
|
7545
|
+
).max(CREDENTIALS_PUSH_MAX)
|
|
7546
|
+
});
|
|
7547
|
+
var credentialsPushOutputSchema = z4.object({
|
|
7548
|
+
stored: z4.number().int().nonnegative(),
|
|
7549
|
+
kept: z4.number().int().nonnegative()
|
|
7550
|
+
});
|
|
7551
|
+
var SANDBOX_LIST_MAX = 5e3;
|
|
7552
|
+
var SANDBOX_LIST_POOLS_MAX = 500;
|
|
7553
|
+
var SANDBOX_LIST_POOL_REPOS_MAX = 100;
|
|
7554
|
+
var listOutputSchema = z4.object({
|
|
7555
|
+
sandboxes: z4.array(
|
|
7556
|
+
z4.object({
|
|
7557
|
+
handle: id,
|
|
7558
|
+
tenant: tenantIdsSchema.nullable(),
|
|
7559
|
+
repos: z4.array(repoIdentitySchema).max(16),
|
|
7560
|
+
/** The sandbox mounts org-fs. */
|
|
7561
|
+
orgFs: z4.boolean(),
|
|
7562
|
+
/** When the org-fs config the host holds for its tenant expires. */
|
|
7563
|
+
orgFsConfigExpiresAt: expiresAt.nullable()
|
|
7564
|
+
})
|
|
7565
|
+
).max(SANDBOX_LIST_MAX),
|
|
7566
|
+
/** Defaulted so a host that predates pool listing still lists its sandboxes. */
|
|
7567
|
+
pools: z4.array(
|
|
7568
|
+
z4.object({
|
|
7569
|
+
name: id,
|
|
7570
|
+
tenant: id,
|
|
7571
|
+
image: SandboxImageSchema,
|
|
7572
|
+
repos: z4.array(
|
|
7573
|
+
z4.object({
|
|
7574
|
+
repoUrl: z4.string().min(1).max(CLONE_URL_MAX),
|
|
7575
|
+
branch: z4.string().min(1)
|
|
7576
|
+
})
|
|
7577
|
+
).max(SANDBOX_LIST_POOL_REPOS_MAX)
|
|
7578
|
+
})
|
|
7579
|
+
).max(SANDBOX_LIST_POOLS_MAX).default([])
|
|
7580
|
+
});
|
|
7581
|
+
|
|
7582
|
+
// ../sandbox/server/provider/sandbox-server.ts
|
|
7583
|
+
function messageChain(err) {
|
|
7584
|
+
const parts = [];
|
|
7585
|
+
for (let e = err, depth = 0; e && depth < 5; depth++) {
|
|
7586
|
+
parts.push(e instanceof Error ? e.message : String(e));
|
|
7587
|
+
e = e instanceof Error ? e.cause : void 0;
|
|
7588
|
+
}
|
|
7589
|
+
return parts.join(": ");
|
|
7590
|
+
}
|
|
7591
|
+
function tool(def) {
|
|
7592
|
+
return {
|
|
7593
|
+
...def,
|
|
7594
|
+
// A thrown message is all an MCP error carries, so it carries JSON.
|
|
7595
|
+
execute: (input) => def.execute(input).catch((err) => {
|
|
7596
|
+
const provider = sandboxProviderOfError(err);
|
|
7597
|
+
const body = {
|
|
7598
|
+
...err instanceof ConfigRequestError ? {
|
|
7599
|
+
code: "bootstrap-rejected",
|
|
7600
|
+
error: err.message,
|
|
7601
|
+
status: err.status
|
|
7602
|
+
} : { code: "internal", error: messageChain(err) },
|
|
7603
|
+
...provider ? { provider } : {}
|
|
7604
|
+
};
|
|
4697
7605
|
throw new Error(JSON.stringify(body));
|
|
4698
7606
|
})
|
|
4699
7607
|
};
|
|
@@ -4732,7 +7640,8 @@ function sandboxTools(provider, credentials) {
|
|
|
4732
7640
|
alive: daemon !== null,
|
|
4733
7641
|
previewUrl,
|
|
4734
7642
|
daemon,
|
|
4735
|
-
lastTermination
|
|
7643
|
+
lastTermination,
|
|
7644
|
+
provider: await provider().providerOf?.(handle) ?? "kubernetes"
|
|
4736
7645
|
};
|
|
4737
7646
|
}
|
|
4738
7647
|
}),
|
|
@@ -4873,6 +7782,7 @@ function kubeConfigForServer(server) {
|
|
|
4873
7782
|
export {
|
|
4874
7783
|
AgentSandboxProvider,
|
|
4875
7784
|
CREDENTIALS_PUSH_MAX,
|
|
7785
|
+
FreestyleSandboxProvider,
|
|
4876
7786
|
PushedCredentials,
|
|
4877
7787
|
SANDBOX_LIST_MAX,
|
|
4878
7788
|
SANDBOX_LIST_POOLS_MAX,
|
|
@@ -4880,6 +7790,7 @@ export {
|
|
|
4880
7790
|
SANDBOX_TOOLS,
|
|
4881
7791
|
SANDBOX_WATCH_KEEPALIVE_MS,
|
|
4882
7792
|
SANDBOX_WATCH_PATH,
|
|
7793
|
+
SandboxProviderRouter,
|
|
4883
7794
|
TenantPoolCapacityError,
|
|
4884
7795
|
TenantPoolConflictError,
|
|
4885
7796
|
capacityOutputSchema,
|