@rigkit/provider-freestyle 0.0.0-canary-20260518T014918-c5bc0c2

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 ADDED
@@ -0,0 +1,17 @@
1
+ # @rigkit/provider-freestyle
2
+
3
+ Freestyle provider integration for `rig`.
4
+
5
+ This package supplies:
6
+
7
+ - `freestyle.provider(...)` for host Freestyle authentication
8
+ - `freestyle.terminal()` for provider-owned browser terminal sessions targeting Freestyle VMs
9
+ - `providers.freestyle.client` for direct access to the authenticated Freestyle SDK client
10
+ - `providers.freestyle.createSSHOptions(...)` for VM SSH connection options with provider-owned auth handled internally
11
+ - `providers.freestyle.cmux.createSshOptions(...)` and `providers.freestyle.vscode.createUrl(...)` adapter helpers
12
+ - Freestyle SDK exports like `VmSpec` and `VmBaseImage`, so configs use one SDK instance for specs and clients
13
+ - Freestyle-specific JSON state helpers backed by Rigkit provider storage
14
+
15
+ Pass `console.log` to Freestyle SDK calls that accept `logger` to stream SDK progress into the CLI. Console output inside a task handler is intercepted by the Rigkit runtime and emitted as leveled `log.output` events.
16
+
17
+ By default the provider authenticates through a browser login and stores Freestyle credentials in Rigkit's provider host storage, outside project `.rigkit/state.sqlite`. Pass `freestyle.provider({ apiKey })` or `freestyle.provider(apiKey)` to use API-key auth instead.
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@rigkit/provider-freestyle",
3
+ "version": "0.0.0-canary-20260518T014918-c5bc0c2",
4
+ "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/freestyle-sh/rigkit.git",
8
+ "directory": "packages/provider-freestyle"
9
+ },
10
+ "exports": {
11
+ ".": "./src/index.ts",
12
+ "./package.json": "./package.json"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "README.md"
17
+ ],
18
+ "dependencies": {
19
+ "zod": "^4",
20
+ "@rigkit/sdk": "0.0.0-canary-20260518T014918-c5bc0c2",
21
+ "@rigkit/engine": "0.0.0-canary-20260518T014918-c5bc0c2",
22
+ "@rigkit/provider-cmux": "0.0.0-canary-20260518T014918-c5bc0c2"
23
+ },
24
+ "peerDependencies": {
25
+ "freestyle": "^0.1.51"
26
+ },
27
+ "devDependencies": {
28
+ "@types/bun": "latest",
29
+ "freestyle": "^0.1.51",
30
+ "typescript": "latest"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "scripts": {
36
+ "build": "tsc --noEmit",
37
+ "typecheck": "tsc --noEmit",
38
+ "test": "bun test"
39
+ }
40
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,24 @@
1
+ declare const freestyleIdentityIdBrand: unique symbol;
2
+ declare const freestyleTokenIdBrand: unique symbol;
3
+ declare const freestyleTokenBrand: unique symbol;
4
+
5
+ export type FreestyleIdentityId = string & { readonly [freestyleIdentityIdBrand]: true };
6
+ export type FreestyleTokenId = string & { readonly [freestyleTokenIdBrand]: true };
7
+ export type FreestyleToken = string & { readonly [freestyleTokenBrand]: true };
8
+
9
+ export function freestyleIdentityId(value: string): FreestyleIdentityId {
10
+ return nonEmpty(value, "Freestyle identity id") as FreestyleIdentityId;
11
+ }
12
+
13
+ export function freestyleTokenId(value: string): FreestyleTokenId {
14
+ return nonEmpty(value, "Freestyle token id") as FreestyleTokenId;
15
+ }
16
+
17
+ export function freestyleToken(value: string): FreestyleToken {
18
+ return nonEmpty(value, "Freestyle token") as FreestyleToken;
19
+ }
20
+
21
+ function nonEmpty(value: string, label: string): string {
22
+ if (!value) throw new Error(`${label} must be a non-empty string`);
23
+ return value;
24
+ }
@@ -0,0 +1,484 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import type {
3
+ JsonValue,
4
+ ProviderRuntimeContext,
5
+ ProviderStorage,
6
+ ProviderStorageRecord,
7
+ WorkflowProviderController,
8
+ WorkflowEvent,
9
+ } from "@rigkit/engine";
10
+ import { FREESTYLE_PROVIDER_ID, freestyle, freestyleProviderPlugin } from "./index.ts";
11
+ import { createFreestyleProxyFetch } from "./host-auth.ts";
12
+ import type { FreestyleRuntime } from "./provider.ts";
13
+ import { RIGKIT_PROVIDER_FREESTYLE_VERSION } from "./version.ts";
14
+
15
+ const originalFreestyleApiKey = process.env.FREESTYLE_API_KEY;
16
+ const originalFreestyleTeamId = process.env.FREESTYLE_TEAM_ID;
17
+
18
+ afterEach(() => {
19
+ setEnv("FREESTYLE_API_KEY", originalFreestyleApiKey);
20
+ setEnv("FREESTYLE_TEAM_ID", originalFreestyleTeamId);
21
+ });
22
+
23
+ describe("Freestyle provider host auth", () => {
24
+ test("accepts an API key as the provider shorthand", () => {
25
+ expect(freestyle.provider("shorthand-api-key").config).toEqual({
26
+ apiKey: "shorthand-api-key",
27
+ });
28
+ });
29
+
30
+ test("rejects the old nested auth config shape", async () => {
31
+ const projectStorage = new MemoryProviderStorage(FREESTYLE_PROVIDER_ID);
32
+ const hostStorage = new MemoryProviderStorage(FREESTYLE_PROVIDER_ID);
33
+
34
+ await expect(freestyleProviderPlugin.createProvider({
35
+ provider: {
36
+ providerId: FREESTYLE_PROVIDER_ID,
37
+ config: {
38
+ auth: { apiKey: "nested-api-key" },
39
+ },
40
+ },
41
+ storage: projectStorage,
42
+ hostStorage,
43
+ local: { open: async () => {} },
44
+ })).rejects.toThrow("Invalid Freestyle provider config");
45
+ });
46
+
47
+ test("uses explicit API-key auth and stores identity tokens in host storage", async () => {
48
+ process.env.FREESTYLE_API_KEY = "ignored-env-api-key";
49
+ delete process.env.FREESTYLE_TEAM_ID;
50
+
51
+ const projectStorage = new MemoryProviderStorage(FREESTYLE_PROVIDER_ID);
52
+ const hostStorage = new MemoryProviderStorage(FREESTYLE_PROVIDER_ID);
53
+ const requests: Array<{
54
+ url: string;
55
+ method: string;
56
+ authorization: string | null;
57
+ rigkit: string | null;
58
+ rigkitVersion: string | null;
59
+ }> = [];
60
+ const previousFetch = globalThis.fetch;
61
+ globalThis.fetch = testFetch((resource, init) => {
62
+ const url = resourceUrl(resource);
63
+ const method = init?.method ?? "GET";
64
+ const headers = new Headers(init?.headers);
65
+ requests.push({
66
+ url: url.href,
67
+ method,
68
+ authorization: headers.get("authorization"),
69
+ rigkit: headers.get("x-rigkit"),
70
+ rigkitVersion: headers.get("x-rigkit-version"),
71
+ });
72
+ if (url.pathname === "/identity/v1/identities" && method === "POST") {
73
+ return Response.json({ id: "identity-api-key" });
74
+ }
75
+ if (url.pathname === "/identity/v1/identities/identity-api-key/tokens" && method === "POST") {
76
+ return Response.json({ id: "token-id-api-key", token: "ssh-token-api-key" });
77
+ }
78
+ return Response.json({ error: "unexpected request" }, { status: 500 });
79
+ });
80
+
81
+ try {
82
+ const controller = await freestyleProviderPlugin.createProvider({
83
+ provider: {
84
+ providerId: FREESTYLE_PROVIDER_ID,
85
+ config: {
86
+ apiKey: "object-api-key",
87
+ },
88
+ },
89
+ storage: projectStorage,
90
+ hostStorage,
91
+ local: { open: async () => {} },
92
+ });
93
+
94
+ expect(projectStorage.entries()).toEqual([]);
95
+ expect(hostStorage.entries("identity:")).toHaveLength(1);
96
+ expect(requests).toHaveLength(2);
97
+
98
+ const runtime = await (controller as WorkflowProviderController<FreestyleRuntime>).runtime(providerContext([]));
99
+
100
+ expect(runtime.client).toBeDefined();
101
+ expect(hostStorage.entries("identity:")[0]?.value).toMatchObject({
102
+ identityId: "identity-api-key",
103
+ tokenId: "token-id-api-key",
104
+ token: "ssh-token-api-key",
105
+ });
106
+ expect(requests).toEqual([
107
+ {
108
+ url: "https://api.freestyle.sh/identity/v1/identities",
109
+ method: "POST",
110
+ authorization: "Bearer object-api-key",
111
+ rigkit: "true",
112
+ rigkitVersion: RIGKIT_PROVIDER_FREESTYLE_VERSION,
113
+ },
114
+ {
115
+ url: "https://api.freestyle.sh/identity/v1/identities/identity-api-key/tokens",
116
+ method: "POST",
117
+ authorization: "Bearer object-api-key",
118
+ rigkit: "true",
119
+ rigkitVersion: RIGKIT_PROVIDER_FREESTYLE_VERSION,
120
+ },
121
+ ]);
122
+ } finally {
123
+ globalThis.fetch = previousFetch;
124
+ }
125
+ });
126
+
127
+ test("runs browser auth through provider host storage and proxies SDK requests by team", async () => {
128
+ delete process.env.FREESTYLE_API_KEY;
129
+ delete process.env.FREESTYLE_TEAM_ID;
130
+
131
+ const projectStorage = new MemoryProviderStorage(FREESTYLE_PROVIDER_ID);
132
+ const hostStorage = new MemoryProviderStorage(FREESTYLE_PROVIDER_ID);
133
+ const opened: string[] = [];
134
+ const proxyRequests: unknown[] = [];
135
+ const previousFetch = globalThis.fetch;
136
+ globalThis.fetch = testFetch(async (resource, init) => {
137
+ const url = resourceUrl(resource);
138
+ if (url.href === "https://api.stack-auth.com/api/v1/auth/cli") {
139
+ return Response.json({ polling_code: "poll-code", login_code: "login-code" });
140
+ }
141
+ if (url.href === "https://api.stack-auth.com/api/v1/auth/cli/poll") {
142
+ return Response.json({ status: "completed", refresh_token: "refresh-token" });
143
+ }
144
+ if (url.href === "https://api.stack-auth.com/api/v1/auth/sessions/current/refresh") {
145
+ return Response.json({ access_token: "stack-access-token", refresh_token: "refresh-token-rotated" });
146
+ }
147
+ if (url.href === "https://dash.freestyle.sh/api/proxy/request") {
148
+ const body = JSON.parse(String(init?.body));
149
+ proxyRequests.push(body);
150
+ if (body.data.path === "identity/v1/identities") {
151
+ return Response.json({ id: "identity-browser" });
152
+ }
153
+ if (body.data.path === "identity/v1/identities/identity-browser/tokens") {
154
+ return Response.json({ id: "token-id-browser", token: "ssh-token-browser" });
155
+ }
156
+ }
157
+ return Response.json({ error: "unexpected request", url: url.href }, { status: 500 });
158
+ });
159
+
160
+ try {
161
+ const controller = await freestyleProviderPlugin.createProvider({
162
+ provider: {
163
+ providerId: FREESTYLE_PROVIDER_ID,
164
+ config: {
165
+ teamId: "team_123",
166
+ },
167
+ },
168
+ storage: projectStorage,
169
+ hostStorage,
170
+ local: {
171
+ open: async (target) => {
172
+ opened.push(target);
173
+ },
174
+ },
175
+ });
176
+
177
+ expect(opened).toEqual([
178
+ "https://dash.freestyle.sh/handler/cli-auth-confirm?login_code=login-code",
179
+ ]);
180
+ expect(projectStorage.entries()).toEqual([]);
181
+ expect(hostStorage.entries("stack-auth:")[0]?.value).toMatchObject({
182
+ refreshToken: "refresh-token-rotated",
183
+ accessToken: "stack-access-token",
184
+ defaultTeamId: "team_123",
185
+ });
186
+
187
+ await controller.runtime(providerContext([]));
188
+
189
+ expect(hostStorage.entries("identity:")[0]?.value).toMatchObject({
190
+ identityId: "identity-browser",
191
+ tokenId: "token-id-browser",
192
+ token: "ssh-token-browser",
193
+ });
194
+ expect(proxyRequests).toEqual([
195
+ {
196
+ data: {
197
+ accessToken: "stack-access-token",
198
+ teamId: "team_123",
199
+ path: "identity/v1/identities",
200
+ method: "POST",
201
+ headers: expect.objectContaining({
202
+ "x-rigkit": "true",
203
+ "x-rigkit-version": RIGKIT_PROVIDER_FREESTYLE_VERSION,
204
+ }),
205
+ },
206
+ },
207
+ {
208
+ data: {
209
+ accessToken: "stack-access-token",
210
+ teamId: "team_123",
211
+ path: "identity/v1/identities/identity-browser/tokens",
212
+ method: "POST",
213
+ headers: expect.objectContaining({
214
+ "x-rigkit": "true",
215
+ "x-rigkit-version": RIGKIT_PROVIDER_FREESTYLE_VERSION,
216
+ }),
217
+ },
218
+ },
219
+ ]);
220
+ } finally {
221
+ globalThis.fetch = previousFetch;
222
+ }
223
+ });
224
+
225
+ test("ignores ambient FREESTYLE_API_KEY unless API-key auth is configured", async () => {
226
+ process.env.FREESTYLE_API_KEY = "stale-api-key";
227
+ delete process.env.FREESTYLE_TEAM_ID;
228
+
229
+ const projectStorage = new MemoryProviderStorage(FREESTYLE_PROVIDER_ID);
230
+ const hostStorage = new MemoryProviderStorage(FREESTYLE_PROVIDER_ID);
231
+ const opened: string[] = [];
232
+ const requests: Array<{ url: string; authorization: string | null }> = [];
233
+ const previousFetch = globalThis.fetch;
234
+ globalThis.fetch = testFetch(async (resource, init) => {
235
+ const url = resourceUrl(resource);
236
+ requests.push({
237
+ url: url.href,
238
+ authorization: new Headers(init?.headers).get("authorization"),
239
+ });
240
+ if (url.href === "https://api.stack-auth.com/api/v1/auth/cli") {
241
+ return Response.json({ polling_code: "poll-code", login_code: "login-code" });
242
+ }
243
+ if (url.href === "https://api.stack-auth.com/api/v1/auth/cli/poll") {
244
+ return Response.json({ status: "completed", refresh_token: "refresh-token" });
245
+ }
246
+ if (url.href === "https://api.stack-auth.com/api/v1/auth/sessions/current/refresh") {
247
+ return Response.json({ access_token: "stack-access-token", refresh_token: "refresh-token" });
248
+ }
249
+ if (url.href === "https://dash.freestyle.sh/api/proxy/request") {
250
+ const body = JSON.parse(String(init?.body));
251
+ if (body.data.path === "api/cli/teams") {
252
+ return Response.json([{ id: "team_123", displayName: "Team" }]);
253
+ }
254
+ if (body.data.path === "identity/v1/identities") {
255
+ return Response.json({ id: "identity-browser" });
256
+ }
257
+ if (body.data.path === "identity/v1/identities/identity-browser/tokens") {
258
+ return Response.json({ id: "token-id-browser", token: "ssh-token-browser" });
259
+ }
260
+ }
261
+ if (url.href === "https://dash.freestyle.sh/api/cli/teams") {
262
+ return Response.json([{ id: "team_123", displayName: "Team" }]);
263
+ }
264
+ return Response.json({ error: "unexpected request", url: url.href }, { status: 500 });
265
+ });
266
+
267
+ try {
268
+ await freestyleProviderPlugin.createProvider({
269
+ provider: {
270
+ providerId: FREESTYLE_PROVIDER_ID,
271
+ config: {},
272
+ },
273
+ storage: projectStorage,
274
+ hostStorage,
275
+ local: {
276
+ open: async (target) => {
277
+ opened.push(target);
278
+ },
279
+ },
280
+ });
281
+
282
+ expect(opened).toEqual([
283
+ "https://dash.freestyle.sh/handler/cli-auth-confirm?login_code=login-code",
284
+ ]);
285
+ expect(requests.some((request) => request.authorization === "Bearer stale-api-key")).toBe(false);
286
+ expect(hostStorage.entries("identity:")[0]?.value).toMatchObject({
287
+ identityId: "identity-browser",
288
+ });
289
+ } finally {
290
+ globalThis.fetch = previousFetch;
291
+ }
292
+ });
293
+ });
294
+
295
+ describe("Freestyle provider proxy fetch", () => {
296
+ test("preserves Freestyle background request semantics through the browser-auth proxy", async () => {
297
+ const proxyFetch = createFreestyleProxyFetch({
298
+ dashboardUrl: "https://dash.freestyle.sh",
299
+ accessToken: "stack-access-token",
300
+ teamId: "team_123",
301
+ fetch: testFetch(async (resource, init) => {
302
+ const url = resourceUrl(resource);
303
+ expect(url.href).toBe("https://dash.freestyle.sh/api/proxy/request");
304
+ expect(init?.method).toBe("POST");
305
+ expect(new Headers(init?.headers).get("x-rigkit")).toBe("true");
306
+ expect(new Headers(init?.headers).get("x-rigkit-version")).toBe(RIGKIT_PROVIDER_FREESTYLE_VERSION);
307
+
308
+ const body = JSON.parse(String(init?.body));
309
+ expect(body).toMatchObject({
310
+ data: {
311
+ accessToken: "stack-access-token",
312
+ teamId: "team_123",
313
+ path: "v1/vms",
314
+ method: "POST",
315
+ headers: {
316
+ "x-rigkit": "true",
317
+ "x-rigkit-version": RIGKIT_PROVIDER_FREESTYLE_VERSION,
318
+ },
319
+ },
320
+ });
321
+
322
+ return Response.json({
323
+ requestId: "ri_test_123",
324
+ status: "pending",
325
+ resultUrl: "/auth/v1/background-requests/ri_test_123",
326
+ logsUrl: "/observability/v1/logs?requestId=ri_test_123",
327
+ });
328
+ }),
329
+ });
330
+
331
+ const response = await proxyFetch("https://api.freestyle.sh/v1/vms", {
332
+ method: "POST",
333
+ body: "{}",
334
+ });
335
+
336
+ expect(response.status).toBe(202);
337
+ expect(response.headers.get("x-freestyle-background-request-id")).toBe("ri_test_123");
338
+ await expect(response.json()).resolves.toMatchObject({
339
+ requestId: "ri_test_123",
340
+ status: "pending",
341
+ });
342
+ });
343
+
344
+ test("preserves proxy error details for run logs", async () => {
345
+ const proxyFetch = createFreestyleProxyFetch({
346
+ dashboardUrl: "https://dash.freestyle.sh",
347
+ accessToken: "stack-access-token",
348
+ teamId: "team_123",
349
+ fetch: testFetch(async () =>
350
+ Response.json({
351
+ error: "VM setup failed",
352
+ requestId: "req_123",
353
+ logs: ["failed to boot base image"],
354
+ accessToken: "secret-token",
355
+ }, { status: 500 })
356
+ ),
357
+ });
358
+
359
+ const response = await proxyFetch("https://api.freestyle.sh/v1/vms", {
360
+ method: "POST",
361
+ body: "{}",
362
+ });
363
+
364
+ expect(response.status).toBe(500);
365
+ await expect(response.json()).resolves.toEqual({
366
+ code: "INTERNAL_ERROR",
367
+ message: "VM setup failed",
368
+ details: {
369
+ error: "VM setup failed",
370
+ requestId: "req_123",
371
+ logs: ["failed to boot base image"],
372
+ accessToken: "[redacted]",
373
+ },
374
+ });
375
+ });
376
+
377
+ test("redacts sensitive fields on coded proxy errors", async () => {
378
+ const proxyFetch = createFreestyleProxyFetch({
379
+ dashboardUrl: "https://dash.freestyle.sh",
380
+ accessToken: "stack-access-token",
381
+ teamId: "team_123",
382
+ fetch: testFetch(async () =>
383
+ Response.json({
384
+ code: "INTERNAL_ERROR",
385
+ message: "Internal server error",
386
+ requestId: "req_123",
387
+ accessToken: "secret-token",
388
+ }, { status: 500 })
389
+ ),
390
+ });
391
+
392
+ const response = await proxyFetch("https://api.freestyle.sh/v1/vms");
393
+
394
+ expect(response.status).toBe(500);
395
+ await expect(response.json()).resolves.toEqual({
396
+ code: "INTERNAL_ERROR",
397
+ message: "Internal server error",
398
+ requestId: "req_123",
399
+ accessToken: "[redacted]",
400
+ });
401
+ });
402
+ });
403
+
404
+ class MemoryProviderStorage implements ProviderStorage {
405
+ private readonly records = new Map<string, ProviderStorageRecord>();
406
+
407
+ constructor(private readonly providerId: string) {}
408
+
409
+ get<Value extends JsonValue = JsonValue>(key: string): ProviderStorageRecord<Value> | undefined {
410
+ return this.records.get(key) as ProviderStorageRecord<Value> | undefined;
411
+ }
412
+
413
+ set<Value extends JsonValue = JsonValue>(key: string, value: Value): ProviderStorageRecord<Value> {
414
+ const now = new Date().toISOString();
415
+ const existing = this.records.get(key);
416
+ const record: ProviderStorageRecord<Value> = {
417
+ providerId: this.providerId,
418
+ key,
419
+ value,
420
+ createdAt: existing?.createdAt ?? now,
421
+ updatedAt: now,
422
+ };
423
+ this.records.set(key, record as ProviderStorageRecord);
424
+ return record;
425
+ }
426
+
427
+ delete(key: string): void {
428
+ this.records.delete(key);
429
+ }
430
+
431
+ entries(prefix = ""): ProviderStorageRecord[] {
432
+ return [...this.records.values()]
433
+ .filter((record) => record.key.startsWith(prefix))
434
+ .sort((a, b) => a.key.localeCompare(b.key));
435
+ }
436
+ }
437
+
438
+ function providerContext(
439
+ events: WorkflowEvent[],
440
+ local: Partial<ProviderRuntimeContext["local"]> = {},
441
+ ): ProviderRuntimeContext {
442
+ return {
443
+ workflow: "workflow",
444
+ nodePath: "workflow.step",
445
+ emit: (event) => {
446
+ events.push(event);
447
+ },
448
+ interaction: {
449
+ present: async () => {
450
+ throw new Error("unexpected interaction");
451
+ },
452
+ },
453
+ local: {
454
+ open: async () => {},
455
+ ...local,
456
+ },
457
+ metadata: () => {},
458
+ };
459
+ }
460
+
461
+ function testFetch(
462
+ handler: (
463
+ resource: Parameters<typeof fetch>[0],
464
+ init: Parameters<typeof fetch>[1],
465
+ ) => Response | Promise<Response>,
466
+ ): typeof fetch {
467
+ const fetchFn = (async (resource, init) => await handler(resource, init)) as typeof fetch;
468
+ fetchFn.preconnect = () => {};
469
+ return fetchFn;
470
+ }
471
+
472
+ function resourceUrl(resource: Parameters<typeof fetch>[0]): URL {
473
+ if (typeof resource === "string") return new URL(resource);
474
+ if (resource instanceof URL) return resource;
475
+ return new URL(resource.url);
476
+ }
477
+
478
+ function setEnv(name: string, value: string | undefined): void {
479
+ if (value === undefined) {
480
+ delete process.env[name];
481
+ } else {
482
+ process.env[name] = value;
483
+ }
484
+ }