@dbx-tools/appkit-lakebox 0.0.0-stage → 0.9.131

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.
Files changed (55) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +104 -2
  3. package/index.ts +32 -0
  4. package/lib/index.d.ts +28 -0
  5. package/lib/index.js +26 -0
  6. package/lib/src/_package.d.ts +2 -0
  7. package/lib/src/_package.js +11 -0
  8. package/lib/src/_resource-registry.d.ts +41 -0
  9. package/lib/src/_resource-registry.js +128 -0
  10. package/lib/src/_service-snapshots.d.ts +55 -0
  11. package/lib/src/_service-snapshots.js +65 -0
  12. package/lib/src/config.d.ts +40 -0
  13. package/lib/src/config.js +86 -0
  14. package/lib/src/desktop.d.ts +6 -0
  15. package/lib/src/desktop.js +88 -0
  16. package/lib/src/metrics.d.ts +59 -0
  17. package/lib/src/metrics.js +151 -0
  18. package/lib/src/plugin.d.ts +259 -0
  19. package/lib/src/plugin.js +329 -0
  20. package/lib/src/routes.d.ts +75 -0
  21. package/lib/src/routes.js +441 -0
  22. package/lib/src/runtime.d.ts +154 -0
  23. package/lib/src/runtime.js +760 -0
  24. package/lib/src/services.d.ts +101 -0
  25. package/lib/src/services.js +188 -0
  26. package/lib/src/skills.d.ts +5 -0
  27. package/lib/src/skills.js +21 -0
  28. package/lib/src/user.d.ts +46 -0
  29. package/lib/src/user.js +76 -0
  30. package/lib/src/workspace.d.ts +43 -0
  31. package/lib/src/workspace.js +138 -0
  32. package/lib/tsconfig.tsbuildinfo +1 -0
  33. package/package.json +167 -4
  34. package/scripts/desktop-prerequisites.sh +66 -0
  35. package/scripts/desktop-session.sh +307 -0
  36. package/scripts/lakebox-admin.sh +234 -0
  37. package/scripts/lakebox-service.py +449 -0
  38. package/skills/html-page/SKILL.md +40 -0
  39. package/skills/jupyter-lab/SKILL.md +32 -0
  40. package/skills/lakebox-showcase/SKILL.md +66 -0
  41. package/skills/network-speed-test/SKILL.md +29 -0
  42. package/skills/realtime-whiteboard/SKILL.md +28 -0
  43. package/src/_package.ts +11 -0
  44. package/src/_resource-registry.ts +160 -0
  45. package/src/_service-snapshots.ts +84 -0
  46. package/src/config.ts +128 -0
  47. package/src/desktop.ts +97 -0
  48. package/src/metrics.ts +178 -0
  49. package/src/plugin.ts +446 -0
  50. package/src/routes.ts +524 -0
  51. package/src/runtime.ts +952 -0
  52. package/src/services.ts +239 -0
  53. package/src/skills.ts +23 -0
  54. package/src/user.ts +88 -0
  55. package/src/workspace.ts +185 -0
package/src/plugin.ts ADDED
@@ -0,0 +1,446 @@
1
+ import type { IncomingHttpHeaders, IncomingMessage, Server as HttpServer } from "node:http";
2
+ import type { Socket } from "node:net";
3
+ import {
4
+ createWorkspaceClient,
5
+ lakebase,
6
+ toPlugin,
7
+ type IAppRouter,
8
+ type PluginManifest,
9
+ } from "@databricks/appkit";
10
+ import {
11
+ executeFromRegistry,
12
+ type ToolkitEntry,
13
+ type ToolkitOptions,
14
+ type ToolRegistry,
15
+ } from "@databricks/appkit/beta";
16
+ import {
17
+ USER_EMAIL_HEADER,
18
+ USER_ID_HEADER,
19
+ appkit,
20
+ lakebaseResolver,
21
+ pluginRegistry,
22
+ toolkitEntries,
23
+ toolOperations,
24
+ ToolRegistryPlugin,
25
+ type AgentToolExecutionContext,
26
+ } from "@dbx-tools/appkit";
27
+ import {
28
+ createLakeboxServiceOperationSchema,
29
+ executeLakeboxCommandOperationSchema,
30
+ getLakeboxOperationSchema,
31
+ getLakeboxServiceLogsOperationSchema,
32
+ LAKEBOX_PLUGIN_NAME,
33
+ LAKEBOX_ROUTES,
34
+ lakeboxCommandRequestSchema,
35
+ lakeboxServiceActionSchema,
36
+ lakeboxServiceLogsRequestSchema,
37
+ listLakeboxServicesOperationSchema,
38
+ removeLakeboxServiceOperationSchema,
39
+ startLakeboxServiceOperationSchema,
40
+ stopLakeboxServiceOperationSchema,
41
+ type CreateLakeboxServiceRequest,
42
+ type LakeboxClientConfigInput,
43
+ type LakeboxCommandRequest,
44
+ type LakeboxService,
45
+ type LakeboxServiceLogsRequest,
46
+ } from "@dbx-tools/shared-lakebox";
47
+ import type { Application, Request, Response } from "express";
48
+ import type { JSONSchema7 } from "json-schema";
49
+ import { z } from "zod";
50
+ import { LakeboxResourceRegistry } from "./_resource-registry.ts";
51
+ import { LakeboxServiceSnapshots } from "./_service-snapshots.ts";
52
+ import {
53
+ LakeboxPluginConfigSchema,
54
+ resolveLakeboxPluginConfig,
55
+ type LakeboxPluginConfig,
56
+ } from "./config.ts";
57
+ import { LakeboxRuntime, type LakeboxLegacyPool } from "./runtime.ts";
58
+ import { withServiceOrigin } from "./services.ts";
59
+
60
+ interface ServerExtension {
61
+ addExtension(fn: (application: Application) => void): void;
62
+ getServer(): HttpServer;
63
+ }
64
+
65
+ interface UpgradeIdentityGate {
66
+ isProtectedHost(headers: Headers): boolean;
67
+ session(headers: Headers): Promise<string | undefined>;
68
+ }
69
+
70
+ const CONFIG_SCHEMA = z.toJSONSchema(LakeboxPluginConfigSchema) as JSONSchema7;
71
+
72
+ /**
73
+ * AppKit plugin for the single app Lakebox.
74
+ *
75
+ * Registers user-scoped REST routes, agent tools, the deterministic service
76
+ * proxy, and WebSocket upgrade handling. Every Lakebox lifecycle call uses the
77
+ * app service principal; the authenticated request identity only selects the
78
+ * user's Lakebox account and authorizes that user's routes.
79
+ */
80
+ export class AppKitLakeboxPlugin extends ToolRegistryPlugin<LakeboxPluginConfig> {
81
+ static manifest = {
82
+ name: LAKEBOX_PLUGIN_NAME,
83
+ displayName: "Lakebox",
84
+ description:
85
+ "Runs per-user services, terminals, desktops, and commands in one lazily created Databricks Lakebox.",
86
+ stability: "beta",
87
+ resources: { required: [], optional: [] },
88
+ config: { schema: CONFIG_SCHEMA },
89
+ } satisfies PluginManifest<typeof LAKEBOX_PLUGIN_NAME>;
90
+
91
+ readonly runtime: LakeboxRuntime;
92
+ private mounted = false;
93
+ private httpServer?: HttpServer;
94
+ private upgradeAttachTimer?: ReturnType<typeof setTimeout>;
95
+ private upgradeIdentityGate?: UpgradeIdentityGate;
96
+ private readonly upgradeHandler = (request: IncomingMessage, socket: Socket, head: Buffer) => {
97
+ void this.handleUpgrade(request, socket, head);
98
+ };
99
+
100
+ /** Creates a plugin with a supplied shared runtime or one from resolved config. */
101
+ constructor(config: LakeboxPluginConfig = {}) {
102
+ super(config);
103
+ this.runtime = config.runtime ?? new LakeboxRuntime(resolveLakeboxPluginConfig(config));
104
+ }
105
+
106
+ protected get toolRegistry(): ToolRegistry {
107
+ return this.createToolRegistry();
108
+ }
109
+
110
+ override async setup(): Promise<void> {
111
+ this.context?.onLifecycle("setup:complete", async () => {
112
+ const pool = lakebaseResolver.captureServicePrincipalPool(
113
+ pluginRegistry.require(this.context, lakebase, "appkit-lakebox").exports().pool,
114
+ );
115
+ this.runtime.initialize(
116
+ createWorkspaceClient().config,
117
+ (headers) => this.requestIdentity(headers),
118
+ new LakeboxServiceSnapshots(),
119
+ new LakeboxResourceRegistry(this.runtime.config.application, pool),
120
+ );
121
+ void this.runtime.cleanupLegacy(pool as LakeboxLegacyPool);
122
+ const server = this.context?.getPlugins().get("server") as ServerExtension | undefined;
123
+ if (!server || this.mounted) return;
124
+ this.upgradeIdentityGate = resolveUpgradeIdentityGate(
125
+ this.context?.getPlugins().get("authGate"),
126
+ );
127
+ server.addExtension((application) => application.use(this.runtime.routes.middleware()));
128
+ this.mounted = true;
129
+ this.attachUpgradeHandler(server);
130
+ });
131
+ }
132
+
133
+ override injectRoutes(router: IAppRouter): void {
134
+ const json = (
135
+ name: string,
136
+ method: "get" | "post" | "delete",
137
+ path: string,
138
+ handler: (identity: string, req: Request) => Promise<unknown>,
139
+ ) =>
140
+ this.route(router, {
141
+ name,
142
+ method,
143
+ path,
144
+ handler: async (req, res) => {
145
+ res.json(await handler(this.resolveUserId(req), req));
146
+ },
147
+ });
148
+ json("status", "get", LAKEBOX_ROUTES.status, (identity) => this.runtime.summary(identity));
149
+ json("start", "post", LAKEBOX_ROUTES.start, async (identity) => {
150
+ await this.runtime.start();
151
+ return this.runtime.summary(identity);
152
+ });
153
+ json("stop", "post", LAKEBOX_ROUTES.stop, async (identity) => {
154
+ await this.runtime.stop();
155
+ return this.runtime.summary(identity);
156
+ });
157
+ json(
158
+ "executeCommand",
159
+ "post",
160
+ LAKEBOX_ROUTES.commands,
161
+ (identity, req) =>
162
+ this.runtime.execute(identity, lakeboxCommandRequestSchema.parse(req.body ?? {})).result,
163
+ );
164
+ json("services", "get", LAKEBOX_ROUTES.services, async (identity) => ({
165
+ services: await this.runtime.listServices(identity),
166
+ }));
167
+ json("createService", "post", LAKEBOX_ROUTES.services, async (identity, req) => ({
168
+ service: await this.runtime.createService(identity, req.body ?? {}),
169
+ }));
170
+ json("startService", "post", LAKEBOX_ROUTES.serviceStart, async (identity, req) => ({
171
+ service: await this.runtime.startService(identity, serviceName(req)),
172
+ }));
173
+ json("stopService", "post", LAKEBOX_ROUTES.serviceStop, async (identity, req) => ({
174
+ service: await this.runtime.stopService(identity, serviceName(req)),
175
+ }));
176
+ json("removeService", "delete", LAKEBOX_ROUTES.service, async (identity, req) => ({
177
+ removed: await this.runtime.removeService(identity, serviceName(req)),
178
+ }));
179
+ json("serviceLogs", "get", LAKEBOX_ROUTES.serviceLogs, (identity, req) =>
180
+ this.runtime.serviceLogs(
181
+ identity,
182
+ lakeboxServiceLogsRequestSchema.parse({
183
+ name: req.params.name,
184
+ maxLines: req.query.maxLines,
185
+ maxChars: req.query.maxChars,
186
+ }),
187
+ ),
188
+ );
189
+ json("metrics", "get", LAKEBOX_ROUTES.metrics, () => this.runtime.metricsResponse());
190
+ this.route(router, {
191
+ name: "metricsStream",
192
+ method: "get",
193
+ path: LAKEBOX_ROUTES.metricsStream,
194
+ handler: async (req, res) => this.streamMetrics(req, res),
195
+ });
196
+ }
197
+
198
+ /** Returns browser-safe route defaults published through AppKit client config. */
199
+ clientConfig(): LakeboxClientConfigInput {
200
+ return { routePrefix: this.runtime.config.routePrefix };
201
+ }
202
+
203
+ /** Returns selected AppKit toolkit entries backed by this plugin's agent tools. */
204
+ toolkit(options?: ToolkitOptions): Record<string, ToolkitEntry> {
205
+ return toolkitEntries.entries(LAKEBOX_PLUGIN_NAME, this.getAgentTools(), {
206
+ ...options,
207
+ prefix: options?.prefix ?? "",
208
+ });
209
+ }
210
+
211
+ /** Executes one registered agent tool for the current execution identity. */
212
+ override executeAgentTool(
213
+ name: string,
214
+ args: unknown,
215
+ signal?: AbortSignal,
216
+ context?: AgentToolExecutionContext,
217
+ ): Promise<unknown> {
218
+ return executeFromRegistry(
219
+ this.createToolRegistry(context?.resourceId, context?.origin),
220
+ name,
221
+ args,
222
+ signal,
223
+ );
224
+ }
225
+
226
+ /** Returns the typed sibling-plugin API. */
227
+ exports() {
228
+ return {
229
+ summary: (identity: string) => this.runtime.summary(identity),
230
+ start: () => this.runtime.start(),
231
+ stop: () => this.runtime.stop(),
232
+ delete: () => this.runtime.delete(),
233
+ executeCommand: (identity: string, input: LakeboxCommandRequest) =>
234
+ this.runtime.execute(identity, input).result,
235
+ listServices: (identity: string) => this.runtime.listServices(identity),
236
+ createService: (identity: string, input: CreateLakeboxServiceRequest) =>
237
+ this.runtime.createService(identity, input),
238
+ startService: (identity: string, name: string) => this.runtime.startService(identity, name),
239
+ stopService: (identity: string, name: string) => this.runtime.stopService(identity, name),
240
+ removeService: (identity: string, name: string) => this.runtime.removeService(identity, name),
241
+ serviceLogs: (identity: string, input: LakeboxServiceLogsRequest) =>
242
+ this.runtime.serviceLogs(identity, input),
243
+ operations: (identity?: string) => this.operations(identity),
244
+ toolkit: (options?: ToolkitOptions) => this.toolkit(options),
245
+ };
246
+ }
247
+
248
+ /** Cancels in-flight work before shutdown tears down resources. */
249
+ override abortActiveOperations(): void {
250
+ this.runtime.abortActiveOperations();
251
+ }
252
+
253
+ /** Detaches WebSocket handling and closes the shared runtime. */
254
+ async shutdown(): Promise<void> {
255
+ if (this.upgradeAttachTimer) clearTimeout(this.upgradeAttachTimer);
256
+ this.upgradeAttachTimer = undefined;
257
+ this.httpServer?.off("upgrade", this.upgradeHandler);
258
+ this.httpServer = undefined;
259
+ await this.runtime.close();
260
+ }
261
+
262
+ /** Returns the Zod-owned operation contracts used by agent tools. */
263
+ operations(identity?: string, origin?: string) {
264
+ const user = () => identity ?? this.executionIdentity();
265
+ const presentService = (value: LakeboxService) => omitLog(withServiceOrigin(value, origin));
266
+ return toolOperations.defineOperations({
267
+ get_lakebox: {
268
+ schema: getLakeboxOperationSchema,
269
+ execute: async (_input, signal) => {
270
+ const summary = await this.runtime.summary(user(), signal);
271
+ return { ...summary, services: summary.services.map(presentService) };
272
+ },
273
+ },
274
+ execute_lakebox_command: {
275
+ schema: executeLakeboxCommandOperationSchema,
276
+ execute: (input, signal) => this.runtime.execute(user(), input, signal).result,
277
+ },
278
+ list_lakebox_services: {
279
+ schema: listLakeboxServicesOperationSchema,
280
+ execute: async (_input, signal) => ({
281
+ services: (await this.runtime.listServices(user(), signal)).map(presentService),
282
+ }),
283
+ },
284
+ create_lakebox_service: {
285
+ schema: createLakeboxServiceOperationSchema,
286
+ execute: async (input, signal) => ({
287
+ service: presentService(await this.runtime.createService(user(), input, signal)),
288
+ }),
289
+ },
290
+ start_lakebox_service: {
291
+ schema: startLakeboxServiceOperationSchema,
292
+ execute: async (input, signal) => ({
293
+ service: presentService(await this.runtime.startService(user(), input.name, signal)),
294
+ }),
295
+ },
296
+ stop_lakebox_service: {
297
+ schema: stopLakeboxServiceOperationSchema,
298
+ execute: async (input, signal) => ({
299
+ service: presentService(await this.runtime.stopService(user(), input.name, signal)),
300
+ }),
301
+ },
302
+ remove_lakebox_service: {
303
+ schema: removeLakeboxServiceOperationSchema,
304
+ execute: async (input, signal) => ({
305
+ removed: await this.runtime.removeService(user(), input.name, signal),
306
+ }),
307
+ },
308
+ get_lakebox_service_logs: {
309
+ schema: getLakeboxServiceLogsOperationSchema,
310
+ execute: (input, signal) => this.runtime.serviceLogs(user(), input, signal),
311
+ },
312
+ });
313
+ }
314
+
315
+ private createToolRegistry(identity?: string, origin?: string): ToolRegistry {
316
+ return toolOperations.toolRegistryFromOperations(this.operations(identity, origin));
317
+ }
318
+
319
+ private executionIdentity(): string {
320
+ const context = appkit.tryGetExecutionContext();
321
+ if (!context) throw new Error("Lakebox tools require an AppKit execution context");
322
+ return "userId" in context ? context.userId : context.serviceUserId;
323
+ }
324
+
325
+ /**
326
+ * Resolves route identity with the same rules as REST: the forwarded user,
327
+ * or AppKit's development user for direct loopback navigation outside
328
+ * production. Everything else is anonymous and fails closed.
329
+ */
330
+ private requestIdentity(headers: IncomingHttpHeaders): string | undefined {
331
+ const forwarded = headerValue(headers[USER_ID_HEADER])?.trim();
332
+ if (forwarded) return forwarded;
333
+ if (process.env.NODE_ENV !== "development" || !isLoopbackHost(headerValue(headers.host))) {
334
+ return undefined;
335
+ }
336
+ try {
337
+ return this.resolveUserId({
338
+ header: (name: string) => headerValue(headers[name.toLowerCase()]),
339
+ } as unknown as Request);
340
+ } catch {
341
+ return undefined;
342
+ }
343
+ }
344
+
345
+ private async streamMetrics(req: Request, res: Response): Promise<void> {
346
+ const initial = await this.runtime.metricsResponse();
347
+ res.statusCode = 200;
348
+ res.setHeader("content-type", "text/event-stream; charset=utf-8");
349
+ res.setHeader("cache-control", "no-cache, no-transform");
350
+ res.setHeader("connection", "keep-alive");
351
+ res.setHeader("x-accel-buffering", "no");
352
+ res.flushHeaders?.();
353
+ res.write(`data: ${JSON.stringify(initial)}\n\n`);
354
+ const unsubscribe = this.runtime.subscribeMetrics((snapshot) => {
355
+ res.write(`data: ${JSON.stringify({ status: "running", series: [snapshot] })}\n\n`);
356
+ });
357
+ req.once("close", unsubscribe);
358
+ }
359
+
360
+ private attachUpgradeHandler(server: ServerExtension): void {
361
+ if (this.httpServer || this.upgradeAttachTimer) return;
362
+ try {
363
+ this.httpServer = server.getServer();
364
+ this.httpServer.on("upgrade", this.upgradeHandler);
365
+ } catch {
366
+ this.upgradeAttachTimer = setTimeout(() => {
367
+ this.upgradeAttachTimer = undefined;
368
+ this.attachUpgradeHandler(server);
369
+ }, 10);
370
+ this.upgradeAttachTimer.unref?.();
371
+ }
372
+ }
373
+
374
+ private async handleUpgrade(
375
+ request: IncomingMessage,
376
+ socket: Socket,
377
+ head: Buffer,
378
+ ): Promise<void> {
379
+ const path = new URL(request.url ?? "/", "http://localhost").pathname;
380
+ if (!this.runtime.routes.canHandleUpgrade(path)) return;
381
+ const gate = this.upgradeIdentityGate;
382
+ if (gate) {
383
+ const headers = requestHeaders(request);
384
+ if (gate.isProtectedHost(headers)) {
385
+ delete request.headers[USER_ID_HEADER];
386
+ delete request.headers[USER_EMAIL_HEADER];
387
+ const userId = await gate.session(headers);
388
+ if (!userId) {
389
+ socket.destroy();
390
+ return;
391
+ }
392
+ request.headers[USER_ID_HEADER] = userId;
393
+ request.headers[USER_EMAIL_HEADER] = userId;
394
+ }
395
+ }
396
+ if (!(await this.runtime.routes.handleUpgrade(request, socket, head))) socket.destroy();
397
+ }
398
+ }
399
+
400
+ function serviceName(req: Request): string {
401
+ return lakeboxServiceActionSchema.parse({ name: req.params.name }).name;
402
+ }
403
+
404
+ function omitLog<T extends { log?: string }>({ log: _log, ...value }: T): Omit<T, "log"> {
405
+ return value;
406
+ }
407
+
408
+ function headerValue(value: string | string[] | undefined): string | undefined {
409
+ return Array.isArray(value) ? value[0] : value;
410
+ }
411
+
412
+ function isLoopbackHost(host: string | undefined): boolean {
413
+ if (!host) return false;
414
+ try {
415
+ const hostname = new URL(`http://${host}`).hostname.toLowerCase();
416
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
417
+ } catch {
418
+ return false;
419
+ }
420
+ }
421
+
422
+ function resolveUpgradeIdentityGate(plugin: unknown): UpgradeIdentityGate | undefined {
423
+ const exports = (plugin as { exports?: () => unknown } | undefined)?.exports?.();
424
+ if (
425
+ typeof (exports as UpgradeIdentityGate | undefined)?.isProtectedHost !== "function" ||
426
+ typeof (exports as UpgradeIdentityGate | undefined)?.session !== "function"
427
+ ) {
428
+ return undefined;
429
+ }
430
+ return exports as UpgradeIdentityGate;
431
+ }
432
+
433
+ function requestHeaders(request: IncomingMessage): Headers {
434
+ const headers = new Headers();
435
+ for (const [name, value] of Object.entries(request.headers)) {
436
+ if (Array.isArray(value)) {
437
+ for (const item of value) headers.append(name, item);
438
+ } else if (value !== undefined) {
439
+ headers.set(name, value);
440
+ }
441
+ }
442
+ return headers;
443
+ }
444
+
445
+ /** AppKit plugin factory for {@link AppKitLakeboxPlugin}. */
446
+ export const lakebox = toPlugin(AppKitLakeboxPlugin);