@beachside/freestyle 0.0.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # @beachside/freestyle
2
2
 
3
- This 0.0.0 version reserves the package name so releases can use npm trusted publishing. It contains no code.
3
+ Experimental Freestyle VMs adapter for Beachside. Tested on every change against a simulated Freestyle API, and checked by hand against the live service; not yet through the provider qualification suite. Install with `npm install beachside @beachside/freestyle`. Requires Node 20+ or Bun and a Freestyle API key. Built on Freestyle's official SDK, pinned to `freestyle@0.2.21` (public API v5).
4
4
 
5
- Releases are published from https://github.com/pandemicsyn/beachside/blob/main/packages/providers/freestyle.
5
+ ```ts
6
+ import { Beachside } from "beachside";
7
+ import { freestyleAdapter } from "@beachside/freestyle";
8
+
9
+ const client = await Beachside.connect({
10
+ adapter: freestyleAdapter,
11
+ credentials: { apiKey: process.env.FREESTYLE_API_KEY! },
12
+ config: { network: "blocked" },
13
+ });
14
+ try {
15
+ const box = await client.sandboxes.create();
16
+ try {
17
+ console.log((await box.exec(["/bin/sh", "-c", "printf ready"])).stdoutText());
18
+ } finally {
19
+ await box.destroy();
20
+ }
21
+ } finally {
22
+ await client.close();
23
+ }
24
+ ```
25
+
26
+ Freestyle does not reveal which account an API key belongs to, so the scope is a one-way fingerprint of the key plus the fixed `https://api.freestyle.sh` endpoint. References hold the VM id and scope, never the API key, and reopen with the same key.
27
+
28
+ ## Setup defaults
29
+
30
+ | Setting | Behavior |
31
+ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
32
+ | `network` | `blocked` (default) sends an empty Freestyle firewall; `internet` allows every public destination. |
33
+ | `image` | `freestyle/ubuntu` (4 vCPU, 8 GiB). Any Freestyle snapshot: a `sh-…` id, your slug, or a public `owner/slug`. No OCI images. |
34
+ | `resources` | Optional minimum `{ vcpus, memoryMiB }`; picks the smallest Ubuntu base from `freestyle/ubuntu-sm` (2, 4 GiB) to `freestyle/ubuntu-2xl` (32, 64 GiB). Set `image` or `resources`, not both. |
35
+ | `lifecycle.lifetimeSeconds` | 900; 60 s to 30 days. Freestyle deletes the VM that long after creation (`ttlSeconds`). |
36
+ | `lifecycle.idleSeconds` | Optional. Pauses the VM after that long without network activity (`idleTimeoutSeconds`). |
37
+ | `preview` | `{ access: "public", ports: [] }`. Listed ports are published at create on `<slug>-<port>.style.dev`, others on first `preview(port)`; `protected` rejects at connect. |
38
+ | `snapshots` | `preserve` must be memory and disk (Freestyle's only capture); `retentionSeconds` deletes each snapshot that long after capture. |
39
+
40
+ ## Behavior
41
+
42
+ - **Create** boots one VM with a `beachside-<hash>` slug derived from the submission, so a lost response is found by slug and never resent, and waits for `running`. Labels become VM metadata. Create-time `env` is written to `/etc/beachside.env` (mode `0600`, owned by the command user `ubuntu`), which every command, process and terminal reads before its own `env`; a lost or slow create still writes it in the same process, while another process that recovers it reports `OUTCOME_UNKNOWN`.
43
+ - **Exec** supports argv and shell, `cwd`, `env`, finite stdin up to 1 MiB (Freestyle's `stdin` field) and deadlines up to 24 hours on current guest agents. A small `sh` wrapper keeps arguments quoted and returns stdout/stderr as base64, or as raw files for large output, so bytes survive Freestyle's lossy UTF-8 text channel. Output is bounded to 1 MiB across both streams, stdout first.
44
+ - **Processes** run as detached guest jobs whose stdout, stderr and exit status go to private files the adapter polls (base64, byte exact), with FIFO stdin, process-group signals, status and reopen. **Terminals** are named Freestyle PTY sessions (WebSocket) with input, resize, `SIGKILL` and reopen.
45
+ - **Files** read and overwrite up to 8 MiB in one sha256-verified upload, with a root owner check before it and a Python 3 owner repair after replacing a file another user owns; larger files stream through 1 MiB range reads and staged writes published by rename (or hard link for no-clobber). Copy, move, mkdir and remove, and stat, exists and listings on a running VM, use a small Python 3 helper in the VM. On a paused or stopped VM, reads, listings, stat and exists use Freestyle's read-only file API without waking it; writes need a running VM.
46
+ - **Snapshots** capture memory and disk from a running or paused VM under a per-attempt slug; restore boots a new VM from the snapshot id and resumes the captured processes. Inspect, list, delete and expiry (`ttlSeconds`) are supported, and `images.list()` lists the account's snapshots as prepared images.
47
+ - **Suspend/resume** maps to Freestyle pause (memory kept) and start (same processes).
48
+ - **Renew** sets `ttlSeconds` to the VM's age plus the requested window.
49
+ - **Preview** returns the `style.dev` URL for a port, publishing it on first use; the rule names the VM, so Freestyle deletes it with the VM.
50
+ - **Destroy** deletes the VM and waits until Freestyle no longer returns it.
51
+ - Unsupported: OCI images and builds, volumes, mounts and regions (Freestyle has none). Protected previews (Freestyle offers forward auth through your own service) and egress allowlists are not wired yet.
52
+
53
+ Every mutation is sent once. The SDK replays only bodyless reads, follows Freestyle background requests for slow calls, and this adapter adds no retries. 4xx refusals become ordinary errors; lost responses, 5xx and `VM_NON_RESPONSIVE` become `OUTCOME_UNKNOWN` and are never replayed.
54
+
55
+ See the [provider page](https://beachside.run/docs/providers/freestyle/) and the [research issue](https://github.com/pandemicsyn/beachside/issues/129).
@@ -0,0 +1,103 @@
1
+ import { z } from "zod";
2
+ import { type AdapterSession, type CreateInput, type ExecInput, type FileWriteInput, type Sandbox } from "@beachside/adapter";
3
+ import { type PtyDriver } from "./terminal.js";
4
+ import { type NativeFetch } from "./transport.js";
5
+ /** Freestyle's platform default base snapshot: Ubuntu 24.04 with 4 vCPU and 8 GiB. */
6
+ export declare const BASE_IMAGE = "freestyle/ubuntu";
7
+ /**
8
+ * Exec deadlines run up to 24 hours on guest agents with native resumable exec; older agents
9
+ * refuse anything over five minutes with `AGENT_UNSUPPORTED`.
10
+ */
11
+ export declare const MAX_EXEC_SECONDS = 86400;
12
+ /** Per-stream output bound collected through the guest wrapper. */
13
+ export declare const MAX_OUTPUT_BYTES = 1048576;
14
+ /** Native exec stdin limit (1 MiB after decoding). */
15
+ export declare const MAX_STDIN_BYTES = 1048576;
16
+ /**
17
+ * Largest file sent or read in one request. The SDK switches to a chunked upload above this
18
+ * size, and that upload replays chunks on its own, so the adapter stays below it.
19
+ */
20
+ export declare const MAX_FILE_BYTES: number;
21
+ export type FreestyleAdapterOptions = {
22
+ /** Native fetch handed to the official SDK; tests inject a deterministic fixture. */
23
+ fetch?: NativeFetch;
24
+ /** Poll interval while waiting for boot, pause, resume or deletion. */
25
+ pollIntervalMs?: number;
26
+ /** Longest a mutation waits in-call before returning a pending result. */
27
+ settleSeconds?: number;
28
+ /** Delay between output reads while a process is quiet (default 250 ms). */
29
+ processPollMs?: number;
30
+ /** Terminal sessions; defaults to the SDK's `vm.pty` WebSockets. Tests inject a stand-in. */
31
+ pty?: PtyDriver;
32
+ };
33
+ /** Constructing a definition performs no provider IO. */
34
+ export declare function createFreestyleAdapter(options?: FreestyleAdapterOptions): import("@beachside/adapter").AdapterDefinition<z.ZodObject<{
35
+ network: z.ZodDefault<z.ZodEnum<{
36
+ blocked: "blocked";
37
+ internet: "internet";
38
+ }>>;
39
+ image: z.ZodOptional<z.ZodString>;
40
+ resources: z.ZodOptional<z.ZodObject<{
41
+ vcpus: z.ZodOptional<z.ZodNumber>;
42
+ memoryMiB: z.ZodOptional<z.ZodNumber>;
43
+ }, z.core.$strict>>;
44
+ lifecycle: z.ZodDefault<z.ZodObject<{
45
+ lifetimeSeconds: z.ZodDefault<z.ZodNumber>;
46
+ idleSeconds: z.ZodOptional<z.ZodNumber>;
47
+ }, z.core.$strict>>;
48
+ preview: z.ZodDefault<z.ZodObject<{
49
+ access: z.ZodDefault<z.ZodEnum<{
50
+ public: "public";
51
+ protected: "protected";
52
+ }>>;
53
+ ports: z.ZodDefault<z.ZodArray<z.ZodNumber>>;
54
+ }, z.core.$strict>>;
55
+ snapshots: z.ZodOptional<z.ZodObject<{
56
+ preserve: z.ZodOptional<z.ZodEnum<{
57
+ filesystem: "filesystem";
58
+ "filesystem+memory": "filesystem+memory";
59
+ }>>;
60
+ retentionSeconds: z.ZodOptional<z.ZodNumber>;
61
+ }, z.core.$strict>>;
62
+ }, z.core.$strict>, z.ZodObject<{
63
+ apiKey: z.ZodString;
64
+ }, z.core.$strict>, AdapterSession<CreateInput, Sandbox, ExecInput, FileWriteInput, "argv" | "shell", {
65
+ slug: string;
66
+ id?: string | undefined;
67
+ env?: true | undefined;
68
+ }>>;
69
+ export declare const freestyleAdapter: import("@beachside/adapter").AdapterDefinition<z.ZodObject<{
70
+ network: z.ZodDefault<z.ZodEnum<{
71
+ blocked: "blocked";
72
+ internet: "internet";
73
+ }>>;
74
+ image: z.ZodOptional<z.ZodString>;
75
+ resources: z.ZodOptional<z.ZodObject<{
76
+ vcpus: z.ZodOptional<z.ZodNumber>;
77
+ memoryMiB: z.ZodOptional<z.ZodNumber>;
78
+ }, z.core.$strict>>;
79
+ lifecycle: z.ZodDefault<z.ZodObject<{
80
+ lifetimeSeconds: z.ZodDefault<z.ZodNumber>;
81
+ idleSeconds: z.ZodOptional<z.ZodNumber>;
82
+ }, z.core.$strict>>;
83
+ preview: z.ZodDefault<z.ZodObject<{
84
+ access: z.ZodDefault<z.ZodEnum<{
85
+ public: "public";
86
+ protected: "protected";
87
+ }>>;
88
+ ports: z.ZodDefault<z.ZodArray<z.ZodNumber>>;
89
+ }, z.core.$strict>>;
90
+ snapshots: z.ZodOptional<z.ZodObject<{
91
+ preserve: z.ZodOptional<z.ZodEnum<{
92
+ filesystem: "filesystem";
93
+ "filesystem+memory": "filesystem+memory";
94
+ }>>;
95
+ retentionSeconds: z.ZodOptional<z.ZodNumber>;
96
+ }, z.core.$strict>>;
97
+ }, z.core.$strict>, z.ZodObject<{
98
+ apiKey: z.ZodString;
99
+ }, z.core.$strict>, AdapterSession<CreateInput, Sandbox, ExecInput, FileWriteInput, "argv" | "shell", {
100
+ slug: string;
101
+ id?: string | undefined;
102
+ env?: true | undefined;
103
+ }>>;
package/dist/exec.d.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { type Command } from "@beachside/adapter";
2
+ /**
3
+ * Freestyle's `exec-await` runs one command line through the guest shell and returns
4
+ * stdout/stderr as UTF-8 text with invalid bytes replaced, and no documented size bound.
5
+ * To keep argv as arguments and output as bytes, the adapter sends a small POSIX sh
6
+ * wrapper: the user's command writes stdout/stderr to private files in the guest, and the
7
+ * wrapper prints one header line. Small results follow inline as base64; larger ones stay
8
+ * in the guest for a raw-byte download through the filesystem API. User output is never
9
+ * read as a control record.
10
+ */
11
+ export declare const EXEC_RECORD = "BEACHSIDE-EXEC-1";
12
+ /** Combined bound for results returned inline instead of downloaded. */
13
+ export declare const INLINE_BYTES: number;
14
+ /** Single-quote one word for POSIX sh; the only special character inside quotes is `'`. */
15
+ export declare function quote(word: string): string;
16
+ export type WrapperInput = {
17
+ command: Command;
18
+ cwd?: string;
19
+ env?: Record<string, string>;
20
+ /** True when finite stdin bytes travel in the native `stdin` field; otherwise stdin is empty. */
21
+ stdin?: boolean;
22
+ /** Per-stream byte bound applied in the guest. */
23
+ maxBytes: number;
24
+ };
25
+ /**
26
+ * Sandbox-wide environment written at create. Freestyle takes environment variables only per
27
+ * command, so every command, process and terminal sources this file before its own `env`.
28
+ */
29
+ export declare const ENV_FILE = "/etc/beachside.env";
30
+ /** The env file: a marker naming the create attempt, then one quoted export per variable. */
31
+ export declare function envFile(env: Record<string, string>, attempt: string): Uint8Array;
32
+ export declare function envMarker(attempt: string): string;
33
+ /** Throws before any effect when a variable name or value cannot be written. */
34
+ export declare function checkEnv(env: Record<string, string> | undefined): void;
35
+ /** The user's command as one sh line: optional `cd`, quoted exports, then `exec` of the program. */
36
+ export declare function commandLine(input: Pick<WrapperInput, "command" | "cwd" | "env">): string;
37
+ export declare function wrapperScript(input: WrapperInput): string;
38
+ export type WrapperResult = {
39
+ exitCode: number;
40
+ stdout: Uint8Array;
41
+ stderr: Uint8Array;
42
+ truncated: boolean;
43
+ };
44
+ /** Output left in the guest: download `${directory}/stdout` and `${directory}/stderr`, then remove it. */
45
+ export type WrapperFiles = {
46
+ exitCode: number;
47
+ stdoutLength: number;
48
+ stderrLength: number;
49
+ directory: string;
50
+ };
51
+ /** Decode the wrapper record, or null when it is missing or malformed. */
52
+ export declare function parseWrapper(stdout: string): WrapperResult | WrapperFiles | null;
53
+ /**
54
+ * Assemble a downloaded result. A stream shorter than the guest reported keeps the confirmed
55
+ * exit and is marked truncated (`complete: false`); one longer than reported is not this
56
+ * command's output, so the result is null.
57
+ */
58
+ export declare function filesResult(files: WrapperFiles, stdout: Uint8Array, stderr: Uint8Array, maxBytes: number): {
59
+ result: WrapperResult;
60
+ complete: boolean;
61
+ } | null;
@@ -0,0 +1,7 @@
1
+ import { type BoundAdapter } from "@beachside/adapter";
2
+ import type { z } from "zod";
3
+ import { freestyleAdapter } from "./adapter.js";
4
+ /** Credentials and setup in one object: `freestyle({ apiKey, ... })`. */
5
+ export type FreestyleOptions = z.input<typeof freestyleAdapter.credentials> & z.input<typeof freestyleAdapter.config>;
6
+ /** Experimental Freestyle VMs adapter. Same as `Beachside.connect({ adapter: freestyleAdapter, credentials, config })`. */
7
+ export declare function freestyle(options: FreestyleOptions): BoundAdapter;
@@ -0,0 +1,49 @@
1
+ import { type DirectoryResult, type FileStat, type FileTransferContext, type ReadContext } from "@beachside/adapter";
2
+ import { z } from "zod";
3
+ import type { GuestRunner } from "./process.js";
4
+ /**
5
+ * Freestyle's filesystem API reads byte ranges and replaces whole files, but has no append,
6
+ * no-clobber create, copy or move. Those run through this finite helper in the guest, the
7
+ * same one the boxd adapter uses; Python 3 is an image prerequisite, never installed. Paths are
8
+ * arguments, never program text. Streamed writes stage chunks (1 MiB, the native stdin
9
+ * bound) into a private file beside the target and publish it with an atomic rename, or a
10
+ * hard link when the target must not be replaced.
11
+ */
12
+ export declare const FILESYSTEM_HELPER: string;
13
+ /** Bytes appended per staging request, Freestyle's native stdin bound. */
14
+ export declare const STAGE_CHUNK_BYTES = 1048576;
15
+ /** Bytes read per streamed range request. */
16
+ export declare const READ_CHUNK_BYTES = 1048576;
17
+ /**
18
+ * One native range read. `total` is the file length the response declared (`Content-Range`),
19
+ * when it declared one; a range that starts at the end of the file yields no bytes.
20
+ */
21
+ export type RangeReader = (path: string, offset: number, length: number, ctx: ReadContext) => Promise<{
22
+ bytes: Uint8Array;
23
+ total: number | undefined;
24
+ }>;
25
+ /**
26
+ * Decode a native range response. A `206` carries `bytes a-b/total`; a `416` for a range that
27
+ * starts exactly at the end of the file (including an empty file) is EOF;
28
+ * a `200` is the whole file, valid only for a read from offset 0.
29
+ */
30
+ export declare function rangeResult(response: Response, offset: number, length: number): Promise<{
31
+ bytes: Uint8Array;
32
+ total: number | undefined;
33
+ } | null>;
34
+ export declare function filesystem(run: GuestRunner, readRange: RangeReader): {
35
+ helper: (op: string, path: string, destination: string, flag: boolean, ctx: ReadContext, stage?: string, stdin?: Uint8Array) => Promise<z.core.util.JSONType>;
36
+ write: (path: string, bytes: AsyncIterable<Uint8Array> | Iterable<Uint8Array>, overwrite: boolean, ctx: FileTransferContext) => Promise<{
37
+ bytesWritten: number;
38
+ }>;
39
+ transfer(op: "copy" | "move", source: string, destination: string, overwrite: boolean, ctx: FileTransferContext): Promise<{
40
+ acknowledged: true;
41
+ }>;
42
+ /** Native range reads, one chunk per pull, so the file is never held whole. */
43
+ read(path: string, ctx: ReadContext): ReadableStream<Uint8Array<ArrayBufferLike>>;
44
+ };
45
+ export declare function directoryValue(value: z.infer<ReturnType<typeof z.json>>): DirectoryResult;
46
+ export declare function statValue(value: z.infer<ReturnType<typeof z.json>>): FileStat;
47
+ export declare const Exists: z.ZodObject<{
48
+ exists: z.ZodBoolean;
49
+ }, z.core.$strip>;
@@ -0,0 +1,2 @@
1
+ export { BASE_IMAGE, createFreestyleAdapter, freestyleAdapter, type FreestyleAdapterOptions, } from "./adapter.js";
2
+ export { freestyle, type FreestyleOptions } from "./factory.js";