@rigkit/provider-freestyle 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/package.json +8 -5
- package/src/host-auth.test.ts +375 -0
- package/src/host-auth.ts +591 -0
- package/src/index.ts +37 -59
- package/src/provider.test.ts +171 -113
- package/src/provider.ts +106 -380
- package/src/terminal-session.test.ts +42 -2
- package/src/terminal-session.ts +633 -308
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -4,8 +4,14 @@ Freestyle provider integration for `rig`.
|
|
|
4
4
|
|
|
5
5
|
This package supplies:
|
|
6
6
|
|
|
7
|
-
- `freestyle.provider(...)` for Freestyle
|
|
7
|
+
- `freestyle.provider(...)` for host Freestyle authentication
|
|
8
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
|
|
9
11
|
- `providers.freestyle.cmux.createSshOptions(...)` and `providers.freestyle.vscode.createUrl(...)` adapter helpers
|
|
10
|
-
- `
|
|
11
|
-
- Freestyle-specific JSON state helpers backed by
|
|
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 Rigkit's `step.log` to Freestyle SDK calls that accept `logger` to stream SDK progress into the CLI.
|
|
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 `auth: { apiKey }` to use API-key auth instead.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rigkit/provider-freestyle",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -16,14 +16,17 @@
|
|
|
16
16
|
"README.md"
|
|
17
17
|
],
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"freestyle": "latest",
|
|
20
19
|
"zod": "^4",
|
|
21
|
-
"@rigkit/sdk": "0.2.
|
|
22
|
-
"@rigkit/
|
|
23
|
-
"@rigkit/
|
|
20
|
+
"@rigkit/sdk": "0.2.4",
|
|
21
|
+
"@rigkit/engine": "0.2.4",
|
|
22
|
+
"@rigkit/provider-cmux": "0.2.4"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"freestyle": "^0.1.51"
|
|
24
26
|
},
|
|
25
27
|
"devDependencies": {
|
|
26
28
|
"@types/bun": "latest",
|
|
29
|
+
"freestyle": "^0.1.51",
|
|
27
30
|
"typescript": "latest"
|
|
28
31
|
},
|
|
29
32
|
"publishConfig": {
|
|
@@ -0,0 +1,375 @@
|
|
|
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, freestyleProviderPlugin } from "./index.ts";
|
|
11
|
+
import { createFreestyleProxyFetch } from "./host-auth.ts";
|
|
12
|
+
import type { FreestyleRuntime } from "./provider.ts";
|
|
13
|
+
|
|
14
|
+
const originalFreestyleApiKey = process.env.FREESTYLE_API_KEY;
|
|
15
|
+
const originalFreestyleTeamId = process.env.FREESTYLE_TEAM_ID;
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
setEnv("FREESTYLE_API_KEY", originalFreestyleApiKey);
|
|
19
|
+
setEnv("FREESTYLE_TEAM_ID", originalFreestyleTeamId);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("Freestyle provider host auth", () => {
|
|
23
|
+
test("uses explicit API-key auth and stores identity tokens in host storage", async () => {
|
|
24
|
+
process.env.FREESTYLE_API_KEY = "ignored-env-api-key";
|
|
25
|
+
delete process.env.FREESTYLE_TEAM_ID;
|
|
26
|
+
|
|
27
|
+
const projectStorage = new MemoryProviderStorage(FREESTYLE_PROVIDER_ID);
|
|
28
|
+
const hostStorage = new MemoryProviderStorage(FREESTYLE_PROVIDER_ID);
|
|
29
|
+
const requests: Array<{ url: string; method: string; authorization: string | null }> = [];
|
|
30
|
+
const previousFetch = globalThis.fetch;
|
|
31
|
+
globalThis.fetch = testFetch((resource, init) => {
|
|
32
|
+
const url = resourceUrl(resource);
|
|
33
|
+
const method = init?.method ?? "GET";
|
|
34
|
+
const authorization = new Headers(init?.headers).get("authorization");
|
|
35
|
+
requests.push({ url: url.href, method, authorization });
|
|
36
|
+
if (url.pathname === "/identity/v1/identities" && method === "POST") {
|
|
37
|
+
return Response.json({ id: "identity-api-key" });
|
|
38
|
+
}
|
|
39
|
+
if (url.pathname === "/identity/v1/identities/identity-api-key/tokens" && method === "POST") {
|
|
40
|
+
return Response.json({ id: "token-id-api-key", token: "ssh-token-api-key" });
|
|
41
|
+
}
|
|
42
|
+
return Response.json({ error: "unexpected request" }, { status: 500 });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const controller = await freestyleProviderPlugin.createProvider({
|
|
47
|
+
provider: {
|
|
48
|
+
providerId: FREESTYLE_PROVIDER_ID,
|
|
49
|
+
config: {
|
|
50
|
+
auth: { apiKey: "object-api-key" },
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
storage: projectStorage,
|
|
54
|
+
hostStorage,
|
|
55
|
+
local: { open: async () => {} },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(projectStorage.entries()).toEqual([]);
|
|
59
|
+
expect(hostStorage.entries("identity:")).toHaveLength(1);
|
|
60
|
+
expect(requests).toHaveLength(2);
|
|
61
|
+
|
|
62
|
+
const runtime = await (controller as WorkflowProviderController<FreestyleRuntime>).runtime(providerContext([]));
|
|
63
|
+
|
|
64
|
+
expect(runtime.client).toBeDefined();
|
|
65
|
+
expect(hostStorage.entries("identity:")[0]?.value).toMatchObject({
|
|
66
|
+
identityId: "identity-api-key",
|
|
67
|
+
tokenId: "token-id-api-key",
|
|
68
|
+
token: "ssh-token-api-key",
|
|
69
|
+
});
|
|
70
|
+
expect(requests).toEqual([
|
|
71
|
+
{
|
|
72
|
+
url: "https://api.freestyle.sh/identity/v1/identities",
|
|
73
|
+
method: "POST",
|
|
74
|
+
authorization: "Bearer object-api-key",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
url: "https://api.freestyle.sh/identity/v1/identities/identity-api-key/tokens",
|
|
78
|
+
method: "POST",
|
|
79
|
+
authorization: "Bearer object-api-key",
|
|
80
|
+
},
|
|
81
|
+
]);
|
|
82
|
+
} finally {
|
|
83
|
+
globalThis.fetch = previousFetch;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("runs browser auth through provider host storage and proxies SDK requests by team", async () => {
|
|
88
|
+
delete process.env.FREESTYLE_API_KEY;
|
|
89
|
+
delete process.env.FREESTYLE_TEAM_ID;
|
|
90
|
+
|
|
91
|
+
const projectStorage = new MemoryProviderStorage(FREESTYLE_PROVIDER_ID);
|
|
92
|
+
const hostStorage = new MemoryProviderStorage(FREESTYLE_PROVIDER_ID);
|
|
93
|
+
const opened: string[] = [];
|
|
94
|
+
const proxyRequests: unknown[] = [];
|
|
95
|
+
const previousFetch = globalThis.fetch;
|
|
96
|
+
globalThis.fetch = testFetch(async (resource, init) => {
|
|
97
|
+
const url = resourceUrl(resource);
|
|
98
|
+
if (url.href === "https://api.stack-auth.com/api/v1/auth/cli") {
|
|
99
|
+
return Response.json({ polling_code: "poll-code", login_code: "login-code" });
|
|
100
|
+
}
|
|
101
|
+
if (url.href === "https://api.stack-auth.com/api/v1/auth/cli/poll") {
|
|
102
|
+
return Response.json({ status: "completed", refresh_token: "refresh-token" });
|
|
103
|
+
}
|
|
104
|
+
if (url.href === "https://api.stack-auth.com/api/v1/auth/sessions/current/refresh") {
|
|
105
|
+
return Response.json({ access_token: "stack-access-token", refresh_token: "refresh-token-rotated" });
|
|
106
|
+
}
|
|
107
|
+
if (url.href === "https://dash.freestyle.sh/api/proxy/request") {
|
|
108
|
+
const body = JSON.parse(String(init?.body));
|
|
109
|
+
proxyRequests.push(body);
|
|
110
|
+
if (body.data.path === "identity/v1/identities") {
|
|
111
|
+
return Response.json({ id: "identity-browser" });
|
|
112
|
+
}
|
|
113
|
+
if (body.data.path === "identity/v1/identities/identity-browser/tokens") {
|
|
114
|
+
return Response.json({ id: "token-id-browser", token: "ssh-token-browser" });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return Response.json({ error: "unexpected request", url: url.href }, { status: 500 });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const controller = await freestyleProviderPlugin.createProvider({
|
|
122
|
+
provider: {
|
|
123
|
+
providerId: FREESTYLE_PROVIDER_ID,
|
|
124
|
+
config: {
|
|
125
|
+
auth: {
|
|
126
|
+
teamId: "team_123",
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
storage: projectStorage,
|
|
131
|
+
hostStorage,
|
|
132
|
+
local: {
|
|
133
|
+
open: async (target) => {
|
|
134
|
+
opened.push(target);
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(opened).toEqual([
|
|
140
|
+
"https://dash.freestyle.sh/handler/cli-auth-confirm?login_code=login-code",
|
|
141
|
+
]);
|
|
142
|
+
expect(projectStorage.entries()).toEqual([]);
|
|
143
|
+
expect(hostStorage.entries("stack-auth:")[0]?.value).toMatchObject({
|
|
144
|
+
refreshToken: "refresh-token-rotated",
|
|
145
|
+
accessToken: "stack-access-token",
|
|
146
|
+
defaultTeamId: "team_123",
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await controller.runtime(providerContext([]));
|
|
150
|
+
|
|
151
|
+
expect(hostStorage.entries("identity:")[0]?.value).toMatchObject({
|
|
152
|
+
identityId: "identity-browser",
|
|
153
|
+
tokenId: "token-id-browser",
|
|
154
|
+
token: "ssh-token-browser",
|
|
155
|
+
});
|
|
156
|
+
expect(proxyRequests).toEqual([
|
|
157
|
+
{
|
|
158
|
+
data: {
|
|
159
|
+
accessToken: "stack-access-token",
|
|
160
|
+
teamId: "team_123",
|
|
161
|
+
path: "identity/v1/identities",
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: expect.any(Object),
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
data: {
|
|
168
|
+
accessToken: "stack-access-token",
|
|
169
|
+
teamId: "team_123",
|
|
170
|
+
path: "identity/v1/identities/identity-browser/tokens",
|
|
171
|
+
method: "POST",
|
|
172
|
+
headers: expect.any(Object),
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
]);
|
|
176
|
+
} finally {
|
|
177
|
+
globalThis.fetch = previousFetch;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("ignores ambient FREESTYLE_API_KEY unless API-key auth is configured", async () => {
|
|
182
|
+
process.env.FREESTYLE_API_KEY = "stale-api-key";
|
|
183
|
+
delete process.env.FREESTYLE_TEAM_ID;
|
|
184
|
+
|
|
185
|
+
const projectStorage = new MemoryProviderStorage(FREESTYLE_PROVIDER_ID);
|
|
186
|
+
const hostStorage = new MemoryProviderStorage(FREESTYLE_PROVIDER_ID);
|
|
187
|
+
const opened: string[] = [];
|
|
188
|
+
const requests: Array<{ url: string; authorization: string | null }> = [];
|
|
189
|
+
const previousFetch = globalThis.fetch;
|
|
190
|
+
globalThis.fetch = testFetch(async (resource, init) => {
|
|
191
|
+
const url = resourceUrl(resource);
|
|
192
|
+
requests.push({
|
|
193
|
+
url: url.href,
|
|
194
|
+
authorization: new Headers(init?.headers).get("authorization"),
|
|
195
|
+
});
|
|
196
|
+
if (url.href === "https://api.stack-auth.com/api/v1/auth/cli") {
|
|
197
|
+
return Response.json({ polling_code: "poll-code", login_code: "login-code" });
|
|
198
|
+
}
|
|
199
|
+
if (url.href === "https://api.stack-auth.com/api/v1/auth/cli/poll") {
|
|
200
|
+
return Response.json({ status: "completed", refresh_token: "refresh-token" });
|
|
201
|
+
}
|
|
202
|
+
if (url.href === "https://api.stack-auth.com/api/v1/auth/sessions/current/refresh") {
|
|
203
|
+
return Response.json({ access_token: "stack-access-token", refresh_token: "refresh-token" });
|
|
204
|
+
}
|
|
205
|
+
if (url.href === "https://dash.freestyle.sh/api/proxy/request") {
|
|
206
|
+
const body = JSON.parse(String(init?.body));
|
|
207
|
+
if (body.data.path === "api/cli/teams") {
|
|
208
|
+
return Response.json([{ id: "team_123", displayName: "Team" }]);
|
|
209
|
+
}
|
|
210
|
+
if (body.data.path === "identity/v1/identities") {
|
|
211
|
+
return Response.json({ id: "identity-browser" });
|
|
212
|
+
}
|
|
213
|
+
if (body.data.path === "identity/v1/identities/identity-browser/tokens") {
|
|
214
|
+
return Response.json({ id: "token-id-browser", token: "ssh-token-browser" });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (url.href === "https://dash.freestyle.sh/api/cli/teams") {
|
|
218
|
+
return Response.json([{ id: "team_123", displayName: "Team" }]);
|
|
219
|
+
}
|
|
220
|
+
return Response.json({ error: "unexpected request", url: url.href }, { status: 500 });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
await freestyleProviderPlugin.createProvider({
|
|
225
|
+
provider: {
|
|
226
|
+
providerId: FREESTYLE_PROVIDER_ID,
|
|
227
|
+
config: {},
|
|
228
|
+
},
|
|
229
|
+
storage: projectStorage,
|
|
230
|
+
hostStorage,
|
|
231
|
+
local: {
|
|
232
|
+
open: async (target) => {
|
|
233
|
+
opened.push(target);
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
expect(opened).toEqual([
|
|
239
|
+
"https://dash.freestyle.sh/handler/cli-auth-confirm?login_code=login-code",
|
|
240
|
+
]);
|
|
241
|
+
expect(requests.some((request) => request.authorization === "Bearer stale-api-key")).toBe(false);
|
|
242
|
+
expect(hostStorage.entries("identity:")[0]?.value).toMatchObject({
|
|
243
|
+
identityId: "identity-browser",
|
|
244
|
+
});
|
|
245
|
+
} finally {
|
|
246
|
+
globalThis.fetch = previousFetch;
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("Freestyle provider proxy fetch", () => {
|
|
252
|
+
test("preserves Freestyle background request semantics through the browser-auth proxy", async () => {
|
|
253
|
+
const proxyFetch = createFreestyleProxyFetch({
|
|
254
|
+
dashboardUrl: "https://dash.freestyle.sh",
|
|
255
|
+
accessToken: "stack-access-token",
|
|
256
|
+
teamId: "team_123",
|
|
257
|
+
fetch: testFetch(async (resource, init) => {
|
|
258
|
+
const url = resourceUrl(resource);
|
|
259
|
+
expect(url.href).toBe("https://dash.freestyle.sh/api/proxy/request");
|
|
260
|
+
expect(init?.method).toBe("POST");
|
|
261
|
+
|
|
262
|
+
const body = JSON.parse(String(init?.body));
|
|
263
|
+
expect(body).toMatchObject({
|
|
264
|
+
data: {
|
|
265
|
+
accessToken: "stack-access-token",
|
|
266
|
+
teamId: "team_123",
|
|
267
|
+
path: "v1/vms",
|
|
268
|
+
method: "POST",
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
return Response.json({
|
|
273
|
+
requestId: "ri_test_123",
|
|
274
|
+
status: "pending",
|
|
275
|
+
resultUrl: "/auth/v1/background-requests/ri_test_123",
|
|
276
|
+
logsUrl: "/observability/v1/logs?requestId=ri_test_123",
|
|
277
|
+
});
|
|
278
|
+
}),
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const response = await proxyFetch("https://api.freestyle.sh/v1/vms", {
|
|
282
|
+
method: "POST",
|
|
283
|
+
body: "{}",
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
expect(response.status).toBe(202);
|
|
287
|
+
expect(response.headers.get("x-freestyle-background-request-id")).toBe("ri_test_123");
|
|
288
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
289
|
+
requestId: "ri_test_123",
|
|
290
|
+
status: "pending",
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
class MemoryProviderStorage implements ProviderStorage {
|
|
296
|
+
private readonly records = new Map<string, ProviderStorageRecord>();
|
|
297
|
+
|
|
298
|
+
constructor(private readonly providerId: string) {}
|
|
299
|
+
|
|
300
|
+
get<Value extends JsonValue = JsonValue>(key: string): ProviderStorageRecord<Value> | undefined {
|
|
301
|
+
return this.records.get(key) as ProviderStorageRecord<Value> | undefined;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
set<Value extends JsonValue = JsonValue>(key: string, value: Value): ProviderStorageRecord<Value> {
|
|
305
|
+
const now = new Date().toISOString();
|
|
306
|
+
const existing = this.records.get(key);
|
|
307
|
+
const record: ProviderStorageRecord<Value> = {
|
|
308
|
+
providerId: this.providerId,
|
|
309
|
+
key,
|
|
310
|
+
value,
|
|
311
|
+
createdAt: existing?.createdAt ?? now,
|
|
312
|
+
updatedAt: now,
|
|
313
|
+
};
|
|
314
|
+
this.records.set(key, record as ProviderStorageRecord);
|
|
315
|
+
return record;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
delete(key: string): void {
|
|
319
|
+
this.records.delete(key);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
entries(prefix = ""): ProviderStorageRecord[] {
|
|
323
|
+
return [...this.records.values()]
|
|
324
|
+
.filter((record) => record.key.startsWith(prefix))
|
|
325
|
+
.sort((a, b) => a.key.localeCompare(b.key));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function providerContext(
|
|
330
|
+
events: WorkflowEvent[],
|
|
331
|
+
local: Partial<ProviderRuntimeContext["local"]> = {},
|
|
332
|
+
): ProviderRuntimeContext {
|
|
333
|
+
return {
|
|
334
|
+
workflow: "workflow",
|
|
335
|
+
nodePath: "workflow.step",
|
|
336
|
+
emit: (event) => {
|
|
337
|
+
events.push(event);
|
|
338
|
+
},
|
|
339
|
+
interaction: {
|
|
340
|
+
present: async () => {
|
|
341
|
+
throw new Error("unexpected interaction");
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
local: {
|
|
345
|
+
open: async () => {},
|
|
346
|
+
...local,
|
|
347
|
+
},
|
|
348
|
+
metadata: () => {},
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function testFetch(
|
|
353
|
+
handler: (
|
|
354
|
+
resource: Parameters<typeof fetch>[0],
|
|
355
|
+
init: Parameters<typeof fetch>[1],
|
|
356
|
+
) => Response | Promise<Response>,
|
|
357
|
+
): typeof fetch {
|
|
358
|
+
const fetchFn = (async (resource, init) => await handler(resource, init)) as typeof fetch;
|
|
359
|
+
fetchFn.preconnect = () => {};
|
|
360
|
+
return fetchFn;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function resourceUrl(resource: Parameters<typeof fetch>[0]): URL {
|
|
364
|
+
if (typeof resource === "string") return new URL(resource);
|
|
365
|
+
if (resource instanceof URL) return resource;
|
|
366
|
+
return new URL(resource.url);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function setEnv(name: string, value: string | undefined): void {
|
|
370
|
+
if (value === undefined) {
|
|
371
|
+
delete process.env[name];
|
|
372
|
+
} else {
|
|
373
|
+
process.env[name] = value;
|
|
374
|
+
}
|
|
375
|
+
}
|