@treeport/treeport 0.5.0 → 0.6.1
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/bin/treeport.mjs +26 -1
- package/dist/dist-Crk_Xr82.js +735 -0
- package/dist/node/cli/index.js +139 -9
- package/dist/node/server/core/launcher.js +42 -13
- package/dist/node/server/index.js +2823 -269
- package/dist/{update-BW-a6Bd-.js → update-qVp7yL5D.js} +1 -392
- package/dist/web/assets/index-2LLiNn3-.js +146 -0
- package/dist/web/assets/index-DmDs47YU.css +2 -0
- package/dist/web/index.html +2 -2
- package/drizzle/0010_browser_panels_and_permissions.sql +21 -0
- package/drizzle/0011_free_magdalene.sql +1 -0
- package/drizzle/meta/0010_snapshot.json +923 -0
- package/drizzle/meta/0011_snapshot.json +931 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +5 -2
- package/skills/treeport/SKILL.md +58 -2
- package/dist/web/assets/index-Cr4UkmRD.js +0 -146
- package/dist/web/assets/index-he-SubzL.css +0 -2
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as TERMINAL_SELECTION_STOP_SEQUENCE, A as terminalCaptureQuerySchema, C as readTreeFileSchema, D as repositoryTerminalPresetsFileSchema, E as repositoryTerminalPresetSchema, F as updateTerminalPresetSchema, H as SOCKET_IO_PATH, I as updateTerminalSchema, J as TERMINAL_OUTPUT_STALL_TIMEOUT_MS, L as updateWebPanelPermissionGrantSchema, M as treeContextValuesSchema, N as treeFilePathSchema, O as requestWorkspaceOpenSchema, P as updateProjectSchema, Q as TERMINAL_SELECTION_START_SEQUENCE, R as webPanelInputSchema, S as packageUpdateSchema, T as removeWorktreeSchema, U as TERMINAL_CONTROLLER_GRACE_MS, W as TERMINAL_MAX_CLIENT_MESSAGE_BYTES, X as TERMINAL_SELECTION_CLEAR_SEQUENCE, Y as TERMINAL_SCROLL_EXIT_SEQUENCE, Z as TERMINAL_SELECTION_RESTORE_SEQUENCE, _ as openWebPanelSchema, _t as parseBrowserAuth, at as terminalLegacyTakeControlSchema, b as packageReloadSchema, bt as parseBrowserOwnerClientMessage, c as createTerminalPresetSchema, ct as terminalSizeSchema, d as createWorktreeSchema, et as parseTerminalAuth, f as deleteTerminalPresetSchema, ft as browserAgentCommandSchema, g as openBrowserPanelFromTerminalSchema, gt as browserUrlSchema, h as getWebPanelStorageSchema, ht as browserTicketRequestSchema, i as TREE_FILE_MAX_BYTES, it as terminalInputSchema, j as treeContextFieldDefinitionSchema, k as setWebPanelStorageSchema, l as createTerminalSchema, lt as terminalTakeControlSchema, m as formatCommandLine, mt as browserOwnerTicketRequestSchema, n as TERMINAL_MAX_UPLOAD_BYTES, nt as terminalBellAcknowledgementSchema, o as browseDirectoryQuerySchema, ot as terminalOutputAckSchema, p as deleteWebPanelStorageSchema, pt as browserOwnerEndpointSchema, r as TREE_FILE_LIST_MAX_ENTRIES, rt as terminalBinarySchema, s as createBrowserPanelSchema, st as terminalResizeSchema, tt as parseTerminalProgress, u as createWebPanelSchema, ut as webPanelPermissionSchema, v as packageInstallSchema, vt as parseBrowserClientMessage, w as registerProjectSchema, x as packageRemoveSchema, y as packageProjectQuerySchema, yt as parseBrowserOwnerAuth, z as writeTreeFileSchema } from "../../dist-Crk_Xr82.js";
|
|
2
|
+
import { M as parseDurationMs, _ as serviceStatus, a as readLocalUpdateProgress, c as createUpdateStartupReporter, i as isCanonicalTreeportVersion, j as assertLoopbackHost, n as compareTreeportVersions, o as resolveLatestTreeportRelease, r as inspectLocalUpdateInstallation } from "../../update-qVp7yL5D.js";
|
|
2
3
|
import { n as prepareShellIntegration } from "../../shell-integration-Be_c91lw.js";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
3
5
|
import fs from "node:fs/promises";
|
|
4
6
|
import path from "node:path";
|
|
5
7
|
import { z } from "zod";
|
|
6
8
|
import { execFile, spawn } from "node:child_process";
|
|
7
9
|
import crypto from "node:crypto";
|
|
8
|
-
import fsSync, { existsSync } from "node:fs";
|
|
10
|
+
import fsSync, { constants, existsSync } from "node:fs";
|
|
9
11
|
import os from "node:os";
|
|
10
12
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
13
|
import { createServer } from "node:http";
|
|
@@ -34,6 +36,7 @@ import { Hono } from "hono";
|
|
|
34
36
|
import { HTTPException } from "hono/http-exception";
|
|
35
37
|
import { requestId } from "hono/request-id";
|
|
36
38
|
import { validator } from "hono/validator";
|
|
39
|
+
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
37
40
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
38
41
|
import { zValidator } from "@hono/zod-validator";
|
|
39
42
|
import * as Fiber from "effect/Fiber";
|
|
@@ -353,10 +356,12 @@ function loadConfig(env = process.env) {
|
|
|
353
356
|
//#endregion
|
|
354
357
|
//#region src/server/core/database-schema.ts
|
|
355
358
|
var database_schema_exports = /* @__PURE__ */ __exportAll({
|
|
359
|
+
browserPanels: () => browserPanels,
|
|
356
360
|
operations: () => operations,
|
|
357
361
|
projects: () => projects,
|
|
358
362
|
terminalBellStates: () => terminalBellStates,
|
|
359
363
|
terminalPresets: () => terminalPresets,
|
|
364
|
+
webPanelPermissionGrants: () => webPanelPermissionGrants,
|
|
360
365
|
webPanelStorage: () => webPanelStorage,
|
|
361
366
|
webPanels: () => webPanels,
|
|
362
367
|
worktrees: () => worktrees
|
|
@@ -401,6 +406,7 @@ const worktrees = sqliteTable("worktrees", {
|
|
|
401
406
|
kind: text().notNull(),
|
|
402
407
|
tmuxSocketName: text("tmux_socket_name").notNull().unique(),
|
|
403
408
|
managedWrapperPath: text("managed_wrapper_path"),
|
|
409
|
+
treeContextJson: text("tree_context_json").notNull().default("{}"),
|
|
404
410
|
prState: text("pr_state").notNull().default("unknown"),
|
|
405
411
|
prNumber: integer("pr_number"),
|
|
406
412
|
prUrl: text("pr_url"),
|
|
@@ -425,6 +431,14 @@ const terminalBellStates = sqliteTable("terminal_bell_states", {
|
|
|
425
431
|
occurredAt: text("occurred_at").notNull(),
|
|
426
432
|
unread: integer().notNull()
|
|
427
433
|
}, (table) => [check("terminal_bell_states_sequence_check", sql`${table.sequence} > 0`), check("terminal_bell_states_unread_check", sql`${table.unread} IN (0,1)`)]);
|
|
434
|
+
const browserPanels = sqliteTable("browser_panels", {
|
|
435
|
+
id: text().primaryKey(),
|
|
436
|
+
worktreeId: text("worktree_id").notNull().references(() => worktrees.id, { onDelete: "cascade" }),
|
|
437
|
+
title: text().notNull(),
|
|
438
|
+
url: text().notNull(),
|
|
439
|
+
createdAt: text("created_at").notNull(),
|
|
440
|
+
updatedAt: text("updated_at").notNull()
|
|
441
|
+
}, (table) => [index("browser_panels_worktree_order_idx").on(table.worktreeId, table.createdAt, table.id)]);
|
|
428
442
|
const webPanels = sqliteTable("web_panels", {
|
|
429
443
|
id: text().primaryKey(),
|
|
430
444
|
worktreeId: text("worktree_id").notNull().references(() => worktrees.id, { onDelete: "cascade" }),
|
|
@@ -435,6 +449,13 @@ const webPanels = sqliteTable("web_panels", {
|
|
|
435
449
|
createdAt: text("created_at").notNull(),
|
|
436
450
|
updatedAt: text("updated_at").notNull()
|
|
437
451
|
}, (table) => [index("web_panels_worktree_order_idx").on(table.worktreeId, table.createdAt, table.id)]);
|
|
452
|
+
const webPanelPermissionGrants = sqliteTable("web_panel_permission_grants", {
|
|
453
|
+
sourceKey: text("source_key").primaryKey(),
|
|
454
|
+
definitionId: text("definition_id").notNull(),
|
|
455
|
+
permissionsJson: text("permissions_json").notNull(),
|
|
456
|
+
grantedAt: text("granted_at").notNull(),
|
|
457
|
+
updatedAt: text("updated_at").notNull()
|
|
458
|
+
}, (table) => [index("web_panel_permission_definition_idx").on(table.definitionId)]);
|
|
438
459
|
const webPanelStorage = sqliteTable("web_panel_storage", {
|
|
439
460
|
panelId: text("panel_id").notNull().references(() => webPanels.id, { onDelete: "cascade" }),
|
|
440
461
|
key: text().notNull(),
|
|
@@ -764,8 +785,10 @@ const BACKUP_RETENTION = 2;
|
|
|
764
785
|
const createOperationRequestSchema = z.strictObject({
|
|
765
786
|
name: z.string(),
|
|
766
787
|
base: z.enum(["default", "current"]),
|
|
788
|
+
context: treeContextValuesSchema.optional(),
|
|
767
789
|
initialTerminal: z.strictObject({
|
|
768
790
|
name: z.string(),
|
|
791
|
+
initialTitle: z.string().optional(),
|
|
769
792
|
argv: z.array(z.string()).optional(),
|
|
770
793
|
returnToShell: z.boolean().optional(),
|
|
771
794
|
initialSize: z.strictObject({
|
|
@@ -1642,6 +1665,16 @@ var GitAdapter = class {
|
|
|
1642
1665
|
async dirtyState(cwd) {
|
|
1643
1666
|
return (await this.dirtyStatus(cwd)).dirty;
|
|
1644
1667
|
}
|
|
1668
|
+
async worktreeFiles(cwd) {
|
|
1669
|
+
const result = await this.checked(cwd, [
|
|
1670
|
+
"ls-files",
|
|
1671
|
+
"--cached",
|
|
1672
|
+
"--others",
|
|
1673
|
+
"--exclude-standard",
|
|
1674
|
+
"-z"
|
|
1675
|
+
]);
|
|
1676
|
+
return [...new Set(result.stdout.split("\0").filter(Boolean))].sort();
|
|
1677
|
+
}
|
|
1645
1678
|
async worktreeDiff(cwd, defaultBranch) {
|
|
1646
1679
|
const candidates = [`origin/${defaultBranch}`, defaultBranch];
|
|
1647
1680
|
let baseRef = "";
|
|
@@ -2066,7 +2099,7 @@ z.unknown();
|
|
|
2066
2099
|
const manifestPatternsSchema = z.array(z.string());
|
|
2067
2100
|
const webPanelManifestEntrySchema = z.union([z.string(), z.strictObject({
|
|
2068
2101
|
source: z.string(),
|
|
2069
|
-
permissions: z.array(
|
|
2102
|
+
permissions: z.array(webPanelPermissionSchema).optional()
|
|
2070
2103
|
})]);
|
|
2071
2104
|
const EMPTY_SETTINGS = {
|
|
2072
2105
|
raw: {},
|
|
@@ -2478,19 +2511,20 @@ var PackageSystem = class {
|
|
|
2478
2511
|
this.validateManifestPatterns([parsedSource.data], "webPanels", packageJsonPath);
|
|
2479
2512
|
return {
|
|
2480
2513
|
source: parsedSource.data,
|
|
2481
|
-
|
|
2514
|
+
permissions: []
|
|
2482
2515
|
};
|
|
2483
2516
|
}
|
|
2484
2517
|
const { source, permissions = [] } = z.strictObject({
|
|
2485
2518
|
source: z.string(),
|
|
2486
|
-
permissions: z.array(
|
|
2519
|
+
permissions: z.array(webPanelPermissionSchema).optional()
|
|
2487
2520
|
}).parse(parsed.data);
|
|
2488
2521
|
if (source.startsWith("!")) throw new Error(`${packageJsonPath} contains an invalid web panel definition`);
|
|
2489
2522
|
this.validateManifestPatterns([source], "webPanels", packageJsonPath);
|
|
2490
|
-
|
|
2523
|
+
const uniquePermissions = new Set(permissions);
|
|
2524
|
+
if (uniquePermissions.size !== permissions.length) throw new Error(`${packageJsonPath} contains an invalid web panel permission`);
|
|
2491
2525
|
return {
|
|
2492
2526
|
source,
|
|
2493
|
-
|
|
2527
|
+
permissions: [...uniquePermissions]
|
|
2494
2528
|
};
|
|
2495
2529
|
});
|
|
2496
2530
|
}
|
|
@@ -2628,13 +2662,15 @@ var PackageSystem = class {
|
|
|
2628
2662
|
})).then((values) => values.find(Boolean)) : void 0;
|
|
2629
2663
|
const webPanels = applyNormalFilter(panelCandidates.map((candidate) => {
|
|
2630
2664
|
const resourceId = encodeURIComponent(path.posix.basename(candidate.relativePath));
|
|
2631
|
-
const
|
|
2665
|
+
const permissions = [...new Set((manifest?.webPanels ?? []).filter((entry) => this.manifestAllows(candidate.relativePath, [entry.source], "web-panel")).flatMap((entry) => entry.permissions))];
|
|
2632
2666
|
const resolved = {
|
|
2633
2667
|
definition: {
|
|
2634
2668
|
id: `package:${parsed.packageId}:web-panel:${resourceId}`,
|
|
2635
2669
|
title: titleFromPath(candidate.relativePath),
|
|
2636
2670
|
source: metadata,
|
|
2637
|
-
|
|
2671
|
+
permissions,
|
|
2672
|
+
permissionsGranted: permissions.length === 0,
|
|
2673
|
+
sandbox: { allowSameOrigin: permissions.includes("same-origin") }
|
|
2638
2674
|
},
|
|
2639
2675
|
root: candidate.root,
|
|
2640
2676
|
entry: "index.html",
|
|
@@ -3276,6 +3312,120 @@ async function loadRepositoryTerminalPresets(projectId, worktreePath) {
|
|
|
3276
3312
|
};
|
|
3277
3313
|
}
|
|
3278
3314
|
//#endregion
|
|
3315
|
+
//#region src/server/core/tree-context.ts
|
|
3316
|
+
async function loadTreeContextFields({ dataDir, projectRoot }) {
|
|
3317
|
+
const diagnostics = [];
|
|
3318
|
+
const definitionsByScope = [];
|
|
3319
|
+
const settingsFiles = [{
|
|
3320
|
+
scope: "global",
|
|
3321
|
+
path: path.join(dataDir, "settings.json")
|
|
3322
|
+
}, {
|
|
3323
|
+
scope: "project",
|
|
3324
|
+
path: path.join(projectRoot, ".treeport", "settings.json")
|
|
3325
|
+
}];
|
|
3326
|
+
for (const settings of settingsFiles) {
|
|
3327
|
+
const content = await fs.readFile(settings.path, "utf8").catch((error) => {
|
|
3328
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
|
|
3329
|
+
diagnostics.push({
|
|
3330
|
+
scope: settings.scope,
|
|
3331
|
+
path: settings.path,
|
|
3332
|
+
message: `Could not read ${settings.path}: ${error instanceof Error ? error.message : String(error)}`
|
|
3333
|
+
});
|
|
3334
|
+
return null;
|
|
3335
|
+
});
|
|
3336
|
+
if (content === null || content.trim() === "") {
|
|
3337
|
+
definitionsByScope.push([]);
|
|
3338
|
+
continue;
|
|
3339
|
+
}
|
|
3340
|
+
let parsedJson;
|
|
3341
|
+
try {
|
|
3342
|
+
parsedJson = JSON.parse(content);
|
|
3343
|
+
} catch (error) {
|
|
3344
|
+
diagnostics.push({
|
|
3345
|
+
scope: settings.scope,
|
|
3346
|
+
path: settings.path,
|
|
3347
|
+
message: `Could not parse ${settings.path}: ${error instanceof Error ? error.message : String(error)}`
|
|
3348
|
+
});
|
|
3349
|
+
definitionsByScope.push([]);
|
|
3350
|
+
continue;
|
|
3351
|
+
}
|
|
3352
|
+
const parsedSettings = z.looseObject({ treeContext: z.unknown().optional() }).safeParse(parsedJson);
|
|
3353
|
+
if (!parsedSettings.success) {
|
|
3354
|
+
diagnostics.push({
|
|
3355
|
+
scope: settings.scope,
|
|
3356
|
+
path: settings.path,
|
|
3357
|
+
message: `${settings.path} must contain a JSON object`
|
|
3358
|
+
});
|
|
3359
|
+
definitionsByScope.push([]);
|
|
3360
|
+
continue;
|
|
3361
|
+
}
|
|
3362
|
+
if (parsedSettings.data.treeContext === void 0) {
|
|
3363
|
+
definitionsByScope.push([]);
|
|
3364
|
+
continue;
|
|
3365
|
+
}
|
|
3366
|
+
const parsedTreeContext = z.looseObject({ fields: z.unknown().optional() }).safeParse(parsedSettings.data.treeContext);
|
|
3367
|
+
const parsedFields = z.array(z.unknown()).safeParse(parsedTreeContext.success ? parsedTreeContext.data.fields ?? [] : void 0);
|
|
3368
|
+
if (!parsedTreeContext.success || !parsedFields.success) {
|
|
3369
|
+
diagnostics.push({
|
|
3370
|
+
scope: settings.scope,
|
|
3371
|
+
path: settings.path,
|
|
3372
|
+
message: `${settings.path} treeContext.fields must be an array`
|
|
3373
|
+
});
|
|
3374
|
+
definitionsByScope.push([]);
|
|
3375
|
+
continue;
|
|
3376
|
+
}
|
|
3377
|
+
const fields = [];
|
|
3378
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3379
|
+
for (const [index, input] of parsedFields.data.entries()) {
|
|
3380
|
+
if (fields.length === 64) {
|
|
3381
|
+
diagnostics.push({
|
|
3382
|
+
scope: settings.scope,
|
|
3383
|
+
path: settings.path,
|
|
3384
|
+
message: `${settings.path} treeContext.fields cannot contain more than 64 fields`
|
|
3385
|
+
});
|
|
3386
|
+
break;
|
|
3387
|
+
}
|
|
3388
|
+
const parsedField = treeContextFieldDefinitionSchema.safeParse(input);
|
|
3389
|
+
if (!parsedField.success) {
|
|
3390
|
+
diagnostics.push({
|
|
3391
|
+
scope: settings.scope,
|
|
3392
|
+
path: settings.path,
|
|
3393
|
+
message: `${settings.path} treeContext.fields[${index}] is invalid: ${parsedField.error.issues[0]?.message ?? "invalid field"}`
|
|
3394
|
+
});
|
|
3395
|
+
continue;
|
|
3396
|
+
}
|
|
3397
|
+
if (ids.has(parsedField.data.id)) {
|
|
3398
|
+
diagnostics.push({
|
|
3399
|
+
scope: settings.scope,
|
|
3400
|
+
path: settings.path,
|
|
3401
|
+
message: `${settings.path} contains duplicate tree context field ${parsedField.data.id}`
|
|
3402
|
+
});
|
|
3403
|
+
continue;
|
|
3404
|
+
}
|
|
3405
|
+
ids.add(parsedField.data.id);
|
|
3406
|
+
fields.push(parsedField.data);
|
|
3407
|
+
}
|
|
3408
|
+
definitionsByScope.push(fields);
|
|
3409
|
+
}
|
|
3410
|
+
const fields = /* @__PURE__ */ new Map();
|
|
3411
|
+
for (const [index, definitions] of definitionsByScope.entries()) for (const definition of definitions) {
|
|
3412
|
+
if (!fields.has(definition.id) && fields.size === 64) {
|
|
3413
|
+
const settings = settingsFiles[index];
|
|
3414
|
+
diagnostics.push({
|
|
3415
|
+
scope: settings.scope,
|
|
3416
|
+
path: settings.path,
|
|
3417
|
+
message: `Tree context cannot contain more than 64 fields`
|
|
3418
|
+
});
|
|
3419
|
+
break;
|
|
3420
|
+
}
|
|
3421
|
+
fields.set(definition.id, definition);
|
|
3422
|
+
}
|
|
3423
|
+
return {
|
|
3424
|
+
fields: [...fields.values()],
|
|
3425
|
+
diagnostics
|
|
3426
|
+
};
|
|
3427
|
+
}
|
|
3428
|
+
//#endregion
|
|
3279
3429
|
//#region src/server/core/setup.ts
|
|
3280
3430
|
const DEFAULT_SETUP_TIMEOUT_MS = 30 * 6e4;
|
|
3281
3431
|
const MAX_SETUP_OUTPUT = 4e3;
|
|
@@ -3415,16 +3565,19 @@ var dependencies = {
|
|
|
3415
3565
|
"@hono/node-server": "^2.0.10",
|
|
3416
3566
|
"@hono/zod-validator": "^0.9.0",
|
|
3417
3567
|
"@libsql/client": "^0.17.4",
|
|
3568
|
+
"@playwright/cli": "0.1.18",
|
|
3418
3569
|
"@radix-ui/react-context-menu": "^2.3.7",
|
|
3419
3570
|
"@treeport/panel-sdk": "workspace:*",
|
|
3420
3571
|
"@vitejs/plugin-react": "5.2.0",
|
|
3421
3572
|
"@xterm/headless": "^6.0.0",
|
|
3573
|
+
"cmdk": "^1.1.1",
|
|
3422
3574
|
"commander": "14.0.3",
|
|
3423
3575
|
"drizzle-orm": "^0.45.2",
|
|
3424
3576
|
"effect": "^3.22.0",
|
|
3425
3577
|
"hono": "^4.11.7",
|
|
3426
3578
|
"jsonc-parser": "^3.3.1",
|
|
3427
3579
|
"node-pty": "1.2.0-beta.14",
|
|
3580
|
+
"playwright": "1.61.1",
|
|
3428
3581
|
"socket.io": "4.8.3",
|
|
3429
3582
|
"socket.io-client": "4.8.3",
|
|
3430
3583
|
"vite": "8.1.5",
|
|
@@ -3822,7 +3975,7 @@ const TMUX_SELECTION_RESTORE_KEY = TMUX_SELECTION_RESTORE_SEQUENCE.replace("\x1B
|
|
|
3822
3975
|
const TMUX_CONFIG = `# Generated by Treeport. This does not read or modify ~/.tmux.conf.
|
|
3823
3976
|
set -g status off
|
|
3824
3977
|
set -g default-terminal "tmux-256color"
|
|
3825
|
-
set -s terminal-features[999] "xterm
|
|
3978
|
+
set -s terminal-features[999] "xterm*:hyperlinks"
|
|
3826
3979
|
set -g extended-keys on
|
|
3827
3980
|
if-shell -F '#{>=:#{version},3.5}' 'set-option -s extended-keys-format csi-u'
|
|
3828
3981
|
set -g history-limit 50000
|
|
@@ -3834,7 +3987,7 @@ set -g bell-action any
|
|
|
3834
3987
|
set -g visual-bell off
|
|
3835
3988
|
set -g mouse on
|
|
3836
3989
|
set -g destroy-unattached off
|
|
3837
|
-
set -g mode-style "bg=#
|
|
3990
|
+
set -g mode-style "fg=#09090b,bg=#f4f4f5"
|
|
3838
3991
|
set -s set-clipboard external
|
|
3839
3992
|
set -s user-keys[0] "${TMUX_SCROLL_EXIT_KEY}"
|
|
3840
3993
|
set -s user-keys[1] "${TMUX_SELECTION_START_SEQUENCE.replace("\x1B", "\\033")}"
|
|
@@ -3974,6 +4127,7 @@ var TmuxAdapter = class {
|
|
|
3974
4127
|
cwd: input.cwd,
|
|
3975
4128
|
env: environment
|
|
3976
4129
|
};
|
|
4130
|
+
if (input.initialTitle) spec.initialTitle = input.initialTitle;
|
|
3977
4131
|
if (input.fallbackArgv) spec.fallbackArgv = [...input.fallbackArgv];
|
|
3978
4132
|
spec.tmuxExecutable = resolveExecutablePath(this.executable, this.hostEnvironment);
|
|
3979
4133
|
if (shellIntegrationReady) spec.shellIntegrationDir = this.shellIntegrationDir;
|
|
@@ -4413,7 +4567,18 @@ const id = (prefix) => `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
|
4413
4567
|
const WEB_PANEL_STORAGE_MAX_ENTRIES = 256;
|
|
4414
4568
|
const WEB_PANEL_STORAGE_MAX_TOTAL_BYTES = 1024 * 1024;
|
|
4415
4569
|
const WEB_PANEL_STORAGE_MAX_VALUE_BYTES = 64 * 1024;
|
|
4416
|
-
function
|
|
4570
|
+
function mapBrowserPanel(row) {
|
|
4571
|
+
return {
|
|
4572
|
+
id: row.id,
|
|
4573
|
+
kind: "browser",
|
|
4574
|
+
worktreeId: row.worktreeId,
|
|
4575
|
+
title: row.title,
|
|
4576
|
+
url: row.url,
|
|
4577
|
+
createdAt: row.createdAt,
|
|
4578
|
+
updatedAt: row.updatedAt
|
|
4579
|
+
};
|
|
4580
|
+
}
|
|
4581
|
+
function mapWebPanel(row, permissions = [], permissionsGranted = permissions.length === 0) {
|
|
4417
4582
|
const parsedInput = webPanelInputSchema.nullable().safeParse(JSON.parse(row.inputJson));
|
|
4418
4583
|
if (!parsedInput.success) throw new Error(`Web panel ${row.id} has invalid stored launch input`);
|
|
4419
4584
|
return {
|
|
@@ -4426,7 +4591,8 @@ function mapWebPanel(row, allowSameOrigin = false) {
|
|
|
4426
4591
|
input: parsedInput.data,
|
|
4427
4592
|
cwd: row.launchCwd
|
|
4428
4593
|
},
|
|
4429
|
-
|
|
4594
|
+
permissions,
|
|
4595
|
+
sandbox: { allowSameOrigin: permissionsGranted && permissions.includes("same-origin") },
|
|
4430
4596
|
createdAt: row.createdAt,
|
|
4431
4597
|
updatedAt: row.updatedAt
|
|
4432
4598
|
};
|
|
@@ -4468,6 +4634,7 @@ var TreeportService = class {
|
|
|
4468
4634
|
projectLocks = /* @__PURE__ */ new Set();
|
|
4469
4635
|
worktreeMutations = new KeyedTaskQueue();
|
|
4470
4636
|
terminalMutations = new KeyedTaskQueue();
|
|
4637
|
+
treeFileMutations = new KeyedTaskQueue();
|
|
4471
4638
|
removeConfirmationKey = crypto.randomBytes(32);
|
|
4472
4639
|
terminalStates = /* @__PURE__ */ new Map();
|
|
4473
4640
|
closeOnSuccessTerminalIds = /* @__PURE__ */ new Set();
|
|
@@ -4709,18 +4876,25 @@ var TreeportService = class {
|
|
|
4709
4876
|
})]);
|
|
4710
4877
|
worktree.dirty = dirty;
|
|
4711
4878
|
worktree.terminals = terminals;
|
|
4712
|
-
const storedWebPanels = await this.deps.database.db.select().from(webPanels).where(eq(webPanels.worktreeId, worktree.id)).orderBy(asc(webPanels.createdAt), asc(webPanels.id));
|
|
4713
|
-
const definitions = project.availability.state === "available" && storedWebPanels.length > 0 ? await this.
|
|
4879
|
+
const [storedBrowserPanels, storedWebPanels] = await Promise.all([this.deps.database.db.select().from(browserPanels).where(eq(browserPanels.worktreeId, worktree.id)).orderBy(asc(browserPanels.createdAt), asc(browserPanels.id)), this.deps.database.db.select().from(webPanels).where(eq(webPanels.worktreeId, worktree.id)).orderBy(asc(webPanels.createdAt), asc(webPanels.id))]);
|
|
4880
|
+
const definitions = project.availability.state === "available" && storedWebPanels.length > 0 ? await this.listWebPanelDefinitions(worktree.id).catch(() => []) : [];
|
|
4714
4881
|
const definitionsById = new Map(definitions.map((definition) => [definition.id, definition]));
|
|
4715
|
-
worktree.panels = [
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4882
|
+
worktree.panels = [
|
|
4883
|
+
...terminals.map((terminal) => ({
|
|
4884
|
+
id: `panel_${terminal.id}`,
|
|
4885
|
+
kind: "terminal",
|
|
4886
|
+
worktreeId: worktree.id,
|
|
4887
|
+
terminalId: terminal.id,
|
|
4888
|
+
title: terminal.name,
|
|
4889
|
+
createdAt: terminal.createdAt,
|
|
4890
|
+
updatedAt: terminal.updatedAt
|
|
4891
|
+
})),
|
|
4892
|
+
...storedBrowserPanels.map(mapBrowserPanel),
|
|
4893
|
+
...storedWebPanels.map((panel) => {
|
|
4894
|
+
const definition = definitionsById.get(panel.definitionId);
|
|
4895
|
+
return mapWebPanel(panel, definition?.permissions ?? [], definition?.permissionsGranted ?? false);
|
|
4896
|
+
})
|
|
4897
|
+
].sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));
|
|
4724
4898
|
}));
|
|
4725
4899
|
return project;
|
|
4726
4900
|
}))).filter((project) => project !== null);
|
|
@@ -4828,7 +5002,21 @@ var TreeportService = class {
|
|
|
4828
5002
|
}
|
|
4829
5003
|
async removePackage(source, projectId) {
|
|
4830
5004
|
if (projectId) this.packages.syncProjects([await this.getProject(projectId)]);
|
|
5005
|
+
const registeredProjects = projectId ? [await this.getProject(projectId)] : await this.storedProjects();
|
|
5006
|
+
const collectPermissionSourceKeys = async () => {
|
|
5007
|
+
const keys = /* @__PURE__ */ new Set();
|
|
5008
|
+
for (const project of registeredProjects) {
|
|
5009
|
+
const worktree = project.worktrees[0];
|
|
5010
|
+
if (!worktree) continue;
|
|
5011
|
+
const definitions = await this.effectiveWebPanelDefinitions(worktree.id).catch(() => []);
|
|
5012
|
+
for (const definition of definitions) if (definition.permissions.length > 0) keys.add(await this.webPanelPermissionSourceKey(worktree.id, definition));
|
|
5013
|
+
}
|
|
5014
|
+
return keys;
|
|
5015
|
+
};
|
|
5016
|
+
const before = await collectPermissionSourceKeys();
|
|
4831
5017
|
const result = await this.packages.remove(source, projectId);
|
|
5018
|
+
const after = await collectPermissionSourceKeys();
|
|
5019
|
+
for (const sourceKey of before) if (!after.has(sourceKey)) await this.deps.database.db.delete(webPanelPermissionGrants).where(eq(webPanelPermissionGrants.sourceKey, sourceKey));
|
|
4832
5020
|
await this.webPanelRuntime.disposeDevelopmentServers();
|
|
4833
5021
|
await this.packageResourcesChanged(projectId);
|
|
4834
5022
|
return result;
|
|
@@ -4859,6 +5047,13 @@ var TreeportService = class {
|
|
|
4859
5047
|
this.events.publish("project.updated", { projectId });
|
|
4860
5048
|
return await this.getProject(projectId);
|
|
4861
5049
|
}
|
|
5050
|
+
async listTreeContextFields(projectId) {
|
|
5051
|
+
const project = await this.getProject(projectId);
|
|
5052
|
+
return loadTreeContextFields({
|
|
5053
|
+
dataDir: this.deps.config.dataDir,
|
|
5054
|
+
projectRoot: project.rootPath
|
|
5055
|
+
});
|
|
5056
|
+
}
|
|
4862
5057
|
async listTerminalPresets() {
|
|
4863
5058
|
return (await this.deps.database.db.select().from(terminalPresets).orderBy(asc(terminalPresets.createdAt), asc(terminalPresets.id))).map(mapTerminalPreset);
|
|
4864
5059
|
}
|
|
@@ -4976,6 +5171,8 @@ var TreeportService = class {
|
|
|
4976
5171
|
id: `project:${encodeURIComponent(directory.name)}`,
|
|
4977
5172
|
title: words ? `${words[0].toLocaleUpperCase()}${words.slice(1)}` : directory.name,
|
|
4978
5173
|
source: { type: "project" },
|
|
5174
|
+
permissions: [],
|
|
5175
|
+
permissionsGranted: true,
|
|
4979
5176
|
sandbox: { allowSameOrigin: false },
|
|
4980
5177
|
root,
|
|
4981
5178
|
entry,
|
|
@@ -5005,8 +5202,83 @@ var TreeportService = class {
|
|
|
5005
5202
|
return resolved;
|
|
5006
5203
|
})];
|
|
5007
5204
|
}
|
|
5205
|
+
async webPanelPermissionSourceKey(worktreeId, definition) {
|
|
5206
|
+
const worktree = await this.getWorktree(worktreeId);
|
|
5207
|
+
const source = definition.source.type === "project" ? {
|
|
5208
|
+
type: "project",
|
|
5209
|
+
projectId: worktree.projectId,
|
|
5210
|
+
definitionId: definition.id
|
|
5211
|
+
} : {
|
|
5212
|
+
type: "package",
|
|
5213
|
+
scope: definition.source.scope,
|
|
5214
|
+
projectId: definition.source.scope === "project" ? worktree.projectId : null,
|
|
5215
|
+
packageId: definition.source.packageId,
|
|
5216
|
+
source: definition.source.source,
|
|
5217
|
+
definitionId: definition.id
|
|
5218
|
+
};
|
|
5219
|
+
return crypto.createHash("sha256").update(JSON.stringify(source)).digest("hex");
|
|
5220
|
+
}
|
|
5221
|
+
async webPanelPermissionsGranted(worktreeId, definition) {
|
|
5222
|
+
const sourceKey = await this.webPanelPermissionSourceKey(worktreeId, definition);
|
|
5223
|
+
const [grant] = await this.deps.database.db.select({ permissionsJson: webPanelPermissionGrants.permissionsJson }).from(webPanelPermissionGrants).where(eq(webPanelPermissionGrants.sourceKey, sourceKey)).limit(1);
|
|
5224
|
+
if (definition.permissions.length === 0) {
|
|
5225
|
+
if (grant) await this.deps.database.db.delete(webPanelPermissionGrants).where(eq(webPanelPermissionGrants.sourceKey, sourceKey));
|
|
5226
|
+
return true;
|
|
5227
|
+
}
|
|
5228
|
+
const matches = grant?.permissionsJson === JSON.stringify([...definition.permissions].sort());
|
|
5229
|
+
if (grant && !matches) await this.deps.database.db.delete(webPanelPermissionGrants).where(eq(webPanelPermissionGrants.sourceKey, sourceKey));
|
|
5230
|
+
return matches;
|
|
5231
|
+
}
|
|
5008
5232
|
async listWebPanelDefinitions(worktreeId) {
|
|
5009
|
-
|
|
5233
|
+
const definitions = await this.effectiveWebPanelDefinitions(worktreeId);
|
|
5234
|
+
return Promise.all(definitions.map(async ({ root: _root, entry: _entry, packageRoot: _packageRoot, development: _development, packageLockPath: _packageLockPath, definitionId: _definitionId, packageSource: _packageSource, allowNetworkRequests: _allowNetworkRequests, ...definition }) => ({
|
|
5235
|
+
...definition,
|
|
5236
|
+
permissionsGranted: await this.webPanelPermissionsGranted(worktreeId, definition)
|
|
5237
|
+
})));
|
|
5238
|
+
}
|
|
5239
|
+
async setWebPanelPermissionGrant(worktreeId, definitionId, granted, expectedPermissions) {
|
|
5240
|
+
await this.requireAvailableWorktree(worktreeId);
|
|
5241
|
+
const definition = (await this.effectiveWebPanelDefinitions(worktreeId)).find((candidate) => candidate.id === definitionId);
|
|
5242
|
+
if (!definition) throw new DomainError("WEB_PANEL_DEFINITION_NOT_FOUND", "Web panel definition not found", 404);
|
|
5243
|
+
if (JSON.stringify([...definition.permissions].sort()) !== JSON.stringify([...expectedPermissions].sort())) throw new DomainError("WEB_PANEL_PERMISSIONS_CHANGED", "Web panel permissions changed; review them and try again", 409, { permissions: definition.permissions });
|
|
5244
|
+
const sourceKey = await this.webPanelPermissionSourceKey(worktreeId, definition);
|
|
5245
|
+
if (granted && definition.permissions.length > 0) {
|
|
5246
|
+
const timestamp = now();
|
|
5247
|
+
await this.deps.database.db.insert(webPanelPermissionGrants).values({
|
|
5248
|
+
sourceKey,
|
|
5249
|
+
definitionId,
|
|
5250
|
+
permissionsJson: JSON.stringify([...definition.permissions].sort()),
|
|
5251
|
+
grantedAt: timestamp,
|
|
5252
|
+
updatedAt: timestamp
|
|
5253
|
+
}).onConflictDoUpdate({
|
|
5254
|
+
target: webPanelPermissionGrants.sourceKey,
|
|
5255
|
+
set: {
|
|
5256
|
+
permissionsJson: JSON.stringify([...definition.permissions].sort()),
|
|
5257
|
+
updatedAt: timestamp
|
|
5258
|
+
}
|
|
5259
|
+
});
|
|
5260
|
+
} else await this.deps.database.db.delete(webPanelPermissionGrants).where(eq(webPanelPermissionGrants.sourceKey, sourceKey));
|
|
5261
|
+
const affectedPanels = await this.deps.database.db.select({ id: webPanels.id }).from(webPanels).where(and(eq(webPanels.worktreeId, worktreeId), eq(webPanels.definitionId, definitionId)));
|
|
5262
|
+
this.invalidateProjectsSnapshot();
|
|
5263
|
+
for (const panel of affectedPanels) this.events.publish("panel.updated", {
|
|
5264
|
+
worktreeId,
|
|
5265
|
+
panelId: panel.id
|
|
5266
|
+
});
|
|
5267
|
+
return {
|
|
5268
|
+
id: definition.id,
|
|
5269
|
+
title: definition.title,
|
|
5270
|
+
source: definition.source,
|
|
5271
|
+
permissions: definition.permissions,
|
|
5272
|
+
permissionsGranted: definition.permissions.length === 0 ? true : granted,
|
|
5273
|
+
sandbox: definition.sandbox
|
|
5274
|
+
};
|
|
5275
|
+
}
|
|
5276
|
+
async requireWebPanelPermissions(worktreeId, definition) {
|
|
5277
|
+
if (await this.webPanelPermissionsGranted(worktreeId, definition)) return;
|
|
5278
|
+
throw new DomainError("WEB_PANEL_PERMISSION_REQUIRED", `Permission is required before ${definition.title} can open`, 403, {
|
|
5279
|
+
definitionId: definition.id,
|
|
5280
|
+
permissions: definition.permissions
|
|
5281
|
+
});
|
|
5010
5282
|
}
|
|
5011
5283
|
async normalizeWebPanelLaunch(worktree, launch) {
|
|
5012
5284
|
const inputJson = JSON.stringify(launch.input);
|
|
@@ -5030,6 +5302,122 @@ var TreeportService = class {
|
|
|
5030
5302
|
inputJson
|
|
5031
5303
|
};
|
|
5032
5304
|
}
|
|
5305
|
+
async createBrowserPanel(worktreeId, requestedUrl) {
|
|
5306
|
+
await this.requireAvailableWorktree(worktreeId);
|
|
5307
|
+
const parsedUrl = requestedUrl ? browserUrlSchema.safeParse(requestedUrl) : null;
|
|
5308
|
+
if (parsedUrl && !parsedUrl.success) throw new DomainError("INVALID_BROWSER_URL", "Enter an absolute HTTP or HTTPS URL without credentials", 400);
|
|
5309
|
+
const url = parsedUrl ? new URL(parsedUrl.data).href : "about:blank";
|
|
5310
|
+
const timestamp = now();
|
|
5311
|
+
const panel = {
|
|
5312
|
+
id: id("panel"),
|
|
5313
|
+
kind: "browser",
|
|
5314
|
+
worktreeId,
|
|
5315
|
+
title: url === "about:blank" ? "Browser" : new URL(url).host,
|
|
5316
|
+
url,
|
|
5317
|
+
createdAt: timestamp,
|
|
5318
|
+
updatedAt: timestamp
|
|
5319
|
+
};
|
|
5320
|
+
await this.deps.database.db.insert(browserPanels).values({
|
|
5321
|
+
id: panel.id,
|
|
5322
|
+
worktreeId: panel.worktreeId,
|
|
5323
|
+
title: panel.title,
|
|
5324
|
+
url: panel.url,
|
|
5325
|
+
createdAt: panel.createdAt,
|
|
5326
|
+
updatedAt: panel.updatedAt
|
|
5327
|
+
});
|
|
5328
|
+
this.invalidateProjectsSnapshot();
|
|
5329
|
+
this.events.publish("panel.created", {
|
|
5330
|
+
worktreeId,
|
|
5331
|
+
panelId: panel.id
|
|
5332
|
+
});
|
|
5333
|
+
return panel;
|
|
5334
|
+
}
|
|
5335
|
+
async openBrowserPanel(worktreeId, requestedUrl, sourceTerminalId = null, sourcePanelId = null) {
|
|
5336
|
+
if (sourceTerminalId) {
|
|
5337
|
+
const terminal = await this.getTerminalFromBindings(sourceTerminalId);
|
|
5338
|
+
if (worktreeId && terminal.worktreeId !== worktreeId) throw new DomainError("INVALID_PANEL_OPEN_SOURCE", "The source terminal does not belong to the target tree", 400);
|
|
5339
|
+
worktreeId ??= terminal.worktreeId;
|
|
5340
|
+
}
|
|
5341
|
+
if (sourcePanelId) {
|
|
5342
|
+
const sourcePanel = await this.getBrowserPanel(sourcePanelId);
|
|
5343
|
+
if (worktreeId && sourcePanel.worktreeId !== worktreeId) throw new DomainError("INVALID_PANEL_OPEN_SOURCE", "The source Browser does not belong to the target tree", 400);
|
|
5344
|
+
worktreeId ??= sourcePanel.worktreeId;
|
|
5345
|
+
}
|
|
5346
|
+
if (!worktreeId) throw new DomainError("INVALID_PANEL_OPEN_SOURCE", "A target tree or panel source is required", 400);
|
|
5347
|
+
const panel = await this.createBrowserPanel(worktreeId, requestedUrl);
|
|
5348
|
+
this.events.publish("panel.open_requested", {
|
|
5349
|
+
worktreeId,
|
|
5350
|
+
panelId: panel.id,
|
|
5351
|
+
panel,
|
|
5352
|
+
sourceTerminalId,
|
|
5353
|
+
sourcePanelId
|
|
5354
|
+
});
|
|
5355
|
+
return { panel };
|
|
5356
|
+
}
|
|
5357
|
+
async openBrowserPanelFromTerminal(terminalId, requestedUrl) {
|
|
5358
|
+
return this.openBrowserPanel(null, requestedUrl, terminalId, null);
|
|
5359
|
+
}
|
|
5360
|
+
async openBrowserPanelFromPanel(panelId, requestedUrl) {
|
|
5361
|
+
return this.openBrowserPanel(null, requestedUrl, null, panelId);
|
|
5362
|
+
}
|
|
5363
|
+
async getBrowserPanel(panelId) {
|
|
5364
|
+
const [row] = await this.deps.database.db.select().from(browserPanels).where(eq(browserPanels.id, panelId)).limit(1);
|
|
5365
|
+
if (!row) throw new DomainError("PANEL_NOT_FOUND", "Browser not found", 404);
|
|
5366
|
+
await this.requireAvailableWorktree(row.worktreeId);
|
|
5367
|
+
return mapBrowserPanel(row);
|
|
5368
|
+
}
|
|
5369
|
+
async authorizeBrowserPanel(panelId) {
|
|
5370
|
+
const panel = await this.getBrowserPanel(panelId);
|
|
5371
|
+
return {
|
|
5372
|
+
panel,
|
|
5373
|
+
worktreePath: (await this.getWorktree(panel.worktreeId)).path
|
|
5374
|
+
};
|
|
5375
|
+
}
|
|
5376
|
+
async updateBrowserPanelState(panelId, state) {
|
|
5377
|
+
const panel = await this.getBrowserPanel(panelId);
|
|
5378
|
+
const parsedUrl = state.url === "about:blank" ? {
|
|
5379
|
+
success: true,
|
|
5380
|
+
data: "about:blank"
|
|
5381
|
+
} : browserUrlSchema.safeParse(state.url);
|
|
5382
|
+
if (!parsedUrl.success) throw new DomainError("INVALID_BROWSER_URL", "The hosted browser reported an unsupported URL", 400);
|
|
5383
|
+
const url = parsedUrl.data === "about:blank" ? parsedUrl.data : new URL(parsedUrl.data).href;
|
|
5384
|
+
const title = state.title.trim().slice(0, 256) || (url === "about:blank" ? "Browser" : new URL(url).host || "Browser");
|
|
5385
|
+
if (panel.url === url && panel.title === title) return panel;
|
|
5386
|
+
const observedAt = now();
|
|
5387
|
+
const updatedAt = observedAt > panel.updatedAt ? observedAt : new Date(Date.parse(panel.updatedAt) + 1).toISOString();
|
|
5388
|
+
await this.deps.database.db.update(browserPanels).set({
|
|
5389
|
+
url,
|
|
5390
|
+
title,
|
|
5391
|
+
updatedAt
|
|
5392
|
+
}).where(eq(browserPanels.id, panelId));
|
|
5393
|
+
const updated = {
|
|
5394
|
+
...panel,
|
|
5395
|
+
url,
|
|
5396
|
+
title,
|
|
5397
|
+
updatedAt
|
|
5398
|
+
};
|
|
5399
|
+
this.invalidateProjectsSnapshot();
|
|
5400
|
+
this.events.publish("panel.updated", {
|
|
5401
|
+
worktreeId: panel.worktreeId,
|
|
5402
|
+
panelId
|
|
5403
|
+
});
|
|
5404
|
+
return updated;
|
|
5405
|
+
}
|
|
5406
|
+
async deleteBrowserPanel(panelId) {
|
|
5407
|
+
const [row] = await this.deps.database.db.select().from(browserPanels).where(eq(browserPanels.id, panelId)).limit(1);
|
|
5408
|
+
if (!row) throw new DomainError("PANEL_NOT_FOUND", "Browser not found", 404);
|
|
5409
|
+
await this.deps.database.db.delete(browserPanels).where(eq(browserPanels.id, panelId));
|
|
5410
|
+
this.invalidateProjectsSnapshot();
|
|
5411
|
+
this.events.publish("panel.removed", {
|
|
5412
|
+
worktreeId: row.worktreeId,
|
|
5413
|
+
panelId
|
|
5414
|
+
});
|
|
5415
|
+
}
|
|
5416
|
+
async deletePanel(panelId, discardStoredData = false) {
|
|
5417
|
+
const [browserPanel] = await this.deps.database.db.select({ id: browserPanels.id }).from(browserPanels).where(eq(browserPanels.id, panelId)).limit(1);
|
|
5418
|
+
if (browserPanel) return this.deleteBrowserPanel(panelId);
|
|
5419
|
+
return this.deleteWebPanel(panelId, discardStoredData);
|
|
5420
|
+
}
|
|
5033
5421
|
async createWebPanel(worktreeId, definitionId, launch = {
|
|
5034
5422
|
input: null,
|
|
5035
5423
|
cwd: null
|
|
@@ -5037,6 +5425,7 @@ var TreeportService = class {
|
|
|
5037
5425
|
const worktree = await this.requireAvailableWorktree(worktreeId);
|
|
5038
5426
|
const definition = (await this.effectiveWebPanelDefinitions(worktreeId)).find((candidate) => candidate.id === definitionId);
|
|
5039
5427
|
if (!definition) throw new DomainError("WEB_PANEL_DEFINITION_NOT_FOUND", "Web panel definition not found", 404);
|
|
5428
|
+
await this.requireWebPanelPermissions(worktreeId, definition);
|
|
5040
5429
|
const normalized = await this.normalizeWebPanelLaunch(worktree, launch);
|
|
5041
5430
|
const timestamp = now();
|
|
5042
5431
|
const panel = {
|
|
@@ -5046,6 +5435,7 @@ var TreeportService = class {
|
|
|
5046
5435
|
definitionId,
|
|
5047
5436
|
title: definition.title,
|
|
5048
5437
|
launch: normalized.launch,
|
|
5438
|
+
permissions: definition.permissions,
|
|
5049
5439
|
sandbox: definition.sandbox,
|
|
5050
5440
|
createdAt: timestamp,
|
|
5051
5441
|
updatedAt: timestamp
|
|
@@ -5074,11 +5464,14 @@ var TreeportService = class {
|
|
|
5074
5464
|
const worktree = await this.requireAvailableWorktree(worktreeId);
|
|
5075
5465
|
const definition = (await this.effectiveWebPanelDefinitions(worktreeId)).find((candidate) => candidate.id === definitionId);
|
|
5076
5466
|
if (!definition) throw new DomainError("WEB_PANEL_DEFINITION_NOT_FOUND", "Web panel definition not found", 404);
|
|
5467
|
+
await this.requireWebPanelPermissions(worktreeId, definition);
|
|
5077
5468
|
const finish = (result) => {
|
|
5078
5469
|
this.events.publish("panel.open_requested", {
|
|
5079
5470
|
worktreeId,
|
|
5080
5471
|
panelId: result.panel.id,
|
|
5081
|
-
|
|
5472
|
+
panel: result.panel,
|
|
5473
|
+
sourceTerminalId,
|
|
5474
|
+
sourcePanelId: null
|
|
5082
5475
|
});
|
|
5083
5476
|
return result;
|
|
5084
5477
|
};
|
|
@@ -5108,7 +5501,7 @@ var TreeportService = class {
|
|
|
5108
5501
|
inputJson: normalized.inputJson,
|
|
5109
5502
|
launchCwd: normalized.launch.cwd,
|
|
5110
5503
|
updatedAt
|
|
5111
|
-
}, definition.
|
|
5504
|
+
}, definition.permissions, true);
|
|
5112
5505
|
this.invalidateProjectsSnapshot();
|
|
5113
5506
|
this.events.publish("panel.updated", {
|
|
5114
5507
|
worktreeId,
|
|
@@ -5133,10 +5526,149 @@ var TreeportService = class {
|
|
|
5133
5526
|
panelId
|
|
5134
5527
|
});
|
|
5135
5528
|
}
|
|
5529
|
+
async requireWebPanelTreeFiles(panelId) {
|
|
5530
|
+
const [panel] = await this.deps.database.db.select().from(webPanels).where(eq(webPanels.id, panelId)).limit(1);
|
|
5531
|
+
if (!panel) throw new DomainError("PANEL_NOT_FOUND", "Panel not found", 404);
|
|
5532
|
+
const definition = (await this.effectiveWebPanelDefinitions(panel.worktreeId)).find((candidate) => candidate.id === panel.definitionId);
|
|
5533
|
+
if (!definition) throw new DomainError("WEB_PANEL_DEFINITION_NOT_FOUND", "The definition for this panel is unavailable", 404);
|
|
5534
|
+
await this.requireWebPanelPermissions(panel.worktreeId, definition);
|
|
5535
|
+
if (!definition.permissions.includes("tree-files")) throw new DomainError("WEB_PANEL_TREE_FILES_REQUIRED", "This panel does not have permission to access tree files", 403);
|
|
5536
|
+
const worktree = await this.requireAvailableWorktree(panel.worktreeId);
|
|
5537
|
+
return {
|
|
5538
|
+
project: await this.getProject(worktree.projectId),
|
|
5539
|
+
worktree
|
|
5540
|
+
};
|
|
5541
|
+
}
|
|
5542
|
+
async resolveTreeFile(worktreePath, requestedPath) {
|
|
5543
|
+
if (!treeFilePathSchema.safeParse(requestedPath).success) throw new DomainError("INVALID_TREE_FILE_PATH", "File path must be a relative path inside the tree", 400);
|
|
5544
|
+
const root = await fs.realpath(worktreePath);
|
|
5545
|
+
const candidate = path.resolve(root, requestedPath);
|
|
5546
|
+
if (!isPathWithin(candidate, root)) throw new DomainError("INVALID_TREE_FILE_PATH", "File path must stay inside the tree", 400);
|
|
5547
|
+
const canonicalPath = await fs.realpath(candidate).catch((error) => {
|
|
5548
|
+
if (error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR")) return null;
|
|
5549
|
+
throw error;
|
|
5550
|
+
});
|
|
5551
|
+
if (!canonicalPath) throw new DomainError("TREE_FILE_NOT_FOUND", "The selected file does not exist", 404);
|
|
5552
|
+
if (!isPathWithin(canonicalPath, root)) throw new DomainError("INVALID_TREE_FILE_PATH", "File path must stay inside the tree", 400);
|
|
5553
|
+
const stat = await fs.lstat(candidate);
|
|
5554
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new DomainError("TREE_FILE_UNSUPPORTED", "Only existing regular files can be edited", 415);
|
|
5555
|
+
return {
|
|
5556
|
+
canonicalPath,
|
|
5557
|
+
path: path.relative(root, canonicalPath).split(path.sep).join("/")
|
|
5558
|
+
};
|
|
5559
|
+
}
|
|
5560
|
+
async readTreeFileHandle(handle) {
|
|
5561
|
+
const stat = await handle.stat();
|
|
5562
|
+
if (!stat.isFile()) throw new DomainError("TREE_FILE_UNSUPPORTED", "Only existing regular files can be edited", 415);
|
|
5563
|
+
if (stat.size > 2097152) throw new DomainError("TREE_FILE_TOO_LARGE", "Files larger than 2 MiB cannot be edited", 413);
|
|
5564
|
+
const buffer = Buffer.alloc(TREE_FILE_MAX_BYTES + 1);
|
|
5565
|
+
let bytesRead = 0;
|
|
5566
|
+
while (bytesRead < buffer.length) {
|
|
5567
|
+
const read = await handle.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead);
|
|
5568
|
+
if (read.bytesRead === 0) break;
|
|
5569
|
+
bytesRead += read.bytesRead;
|
|
5570
|
+
}
|
|
5571
|
+
if (bytesRead > 2097152) throw new DomainError("TREE_FILE_TOO_LARGE", "Files larger than 2 MiB cannot be edited", 413);
|
|
5572
|
+
const bytes = buffer.subarray(0, bytesRead);
|
|
5573
|
+
let content;
|
|
5574
|
+
try {
|
|
5575
|
+
content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
5576
|
+
} catch {
|
|
5577
|
+
throw new DomainError("TREE_FILE_UNSUPPORTED", "Only UTF-8 text files can be edited", 415);
|
|
5578
|
+
}
|
|
5579
|
+
if (content.includes("\0")) throw new DomainError("TREE_FILE_UNSUPPORTED", "Only UTF-8 text files can be edited", 415);
|
|
5580
|
+
return {
|
|
5581
|
+
bytes,
|
|
5582
|
+
content
|
|
5583
|
+
};
|
|
5584
|
+
}
|
|
5585
|
+
async listTreeFiles(panelId) {
|
|
5586
|
+
const { project, worktree } = await this.requireWebPanelTreeFiles(panelId);
|
|
5587
|
+
const root = await fs.realpath(worktree.path);
|
|
5588
|
+
const paths = [];
|
|
5589
|
+
if (project.kind === "repository") {
|
|
5590
|
+
const candidates = await this.deps.git.worktreeFiles(root);
|
|
5591
|
+
for (const relativePath of candidates) {
|
|
5592
|
+
if (paths.length > 5e4) break;
|
|
5593
|
+
const candidate = path.resolve(root, relativePath);
|
|
5594
|
+
if (!isPathWithin(candidate, root)) continue;
|
|
5595
|
+
const [canonicalPath, stat] = await Promise.all([fs.realpath(candidate).catch(() => null), fs.lstat(candidate).catch(() => null)]);
|
|
5596
|
+
if (canonicalPath && isPathWithin(canonicalPath, root) && stat?.isFile() && !stat.isSymbolicLink()) paths.push(relativePath.split(path.sep).join("/"));
|
|
5597
|
+
}
|
|
5598
|
+
} else {
|
|
5599
|
+
const directories = [""];
|
|
5600
|
+
while (directories.length > 0 && paths.length <= 5e4) {
|
|
5601
|
+
const relativeDirectory = directories.pop();
|
|
5602
|
+
const entries = await fs.readdir(path.join(root, relativeDirectory), { withFileTypes: true }).then((values) => values.sort((left, right) => right.name.localeCompare(left.name)));
|
|
5603
|
+
for (const entry of entries) {
|
|
5604
|
+
if (entry.name === ".git" && entry.isDirectory()) continue;
|
|
5605
|
+
const relativePath = path.join(relativeDirectory, entry.name);
|
|
5606
|
+
if (entry.isDirectory()) directories.push(relativePath);
|
|
5607
|
+
else if (entry.isFile()) {
|
|
5608
|
+
paths.push(relativePath.split(path.sep).join("/"));
|
|
5609
|
+
if (paths.length > 5e4) break;
|
|
5610
|
+
}
|
|
5611
|
+
}
|
|
5612
|
+
}
|
|
5613
|
+
}
|
|
5614
|
+
paths.sort();
|
|
5615
|
+
return {
|
|
5616
|
+
paths: paths.slice(0, TREE_FILE_LIST_MAX_ENTRIES),
|
|
5617
|
+
truncated: paths.length > TREE_FILE_LIST_MAX_ENTRIES
|
|
5618
|
+
};
|
|
5619
|
+
}
|
|
5620
|
+
async readTreeFile(panelId, requestedPath) {
|
|
5621
|
+
const { worktree } = await this.requireWebPanelTreeFiles(panelId);
|
|
5622
|
+
const resolved = await this.resolveTreeFile(worktree.path, requestedPath);
|
|
5623
|
+
const handle = await fs.open(resolved.canonicalPath, constants.O_RDONLY | constants.O_NOFOLLOW).catch((error) => {
|
|
5624
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") throw new DomainError("TREE_FILE_NOT_FOUND", "The selected file does not exist", 404);
|
|
5625
|
+
throw error;
|
|
5626
|
+
});
|
|
5627
|
+
try {
|
|
5628
|
+
const { bytes, content } = await this.readTreeFileHandle(handle);
|
|
5629
|
+
return {
|
|
5630
|
+
path: resolved.path,
|
|
5631
|
+
content,
|
|
5632
|
+
revision: crypto.createHash("sha256").update(bytes).digest("hex")
|
|
5633
|
+
};
|
|
5634
|
+
} finally {
|
|
5635
|
+
await handle.close();
|
|
5636
|
+
}
|
|
5637
|
+
}
|
|
5638
|
+
async writeTreeFile(panelId, input) {
|
|
5639
|
+
const { worktree } = await this.requireWebPanelTreeFiles(panelId);
|
|
5640
|
+
const content = Buffer.from(input.content, "utf8");
|
|
5641
|
+
if (content.length > 2097152) throw new DomainError("TREE_FILE_TOO_LARGE", "Files larger than 2 MiB cannot be edited", 413);
|
|
5642
|
+
if (input.content.includes("\0")) throw new DomainError("TREE_FILE_UNSUPPORTED", "Only UTF-8 text files can be edited", 415);
|
|
5643
|
+
const resolved = await this.resolveTreeFile(worktree.path, input.path);
|
|
5644
|
+
return this.treeFileMutations.enqueue(resolved.canonicalPath, async () => {
|
|
5645
|
+
const handle = await fs.open(resolved.canonicalPath, constants.O_RDWR | constants.O_NOFOLLOW);
|
|
5646
|
+
try {
|
|
5647
|
+
const current = await this.readTreeFileHandle(handle);
|
|
5648
|
+
if (crypto.createHash("sha256").update(current.bytes).digest("hex") !== input.expectedRevision) throw new DomainError("TREE_FILE_CHANGED", "The file changed after it was opened. Reload it before saving.", 409);
|
|
5649
|
+
let offset = 0;
|
|
5650
|
+
while (offset < content.length) {
|
|
5651
|
+
const write = await handle.write(content, offset, content.length - offset, offset);
|
|
5652
|
+
if (write.bytesWritten === 0) throw new Error("Could not write tree file");
|
|
5653
|
+
offset += write.bytesWritten;
|
|
5654
|
+
}
|
|
5655
|
+
await handle.truncate(content.length);
|
|
5656
|
+
await handle.sync();
|
|
5657
|
+
return {
|
|
5658
|
+
path: resolved.path,
|
|
5659
|
+
revision: crypto.createHash("sha256").update(content).digest("hex")
|
|
5660
|
+
};
|
|
5661
|
+
} finally {
|
|
5662
|
+
await handle.close();
|
|
5663
|
+
}
|
|
5664
|
+
});
|
|
5665
|
+
}
|
|
5136
5666
|
async getWebPanelContext(panelId) {
|
|
5137
5667
|
const [panelRow] = await this.deps.database.db.select().from(webPanels).where(eq(webPanels.id, panelId)).limit(1);
|
|
5138
5668
|
if (!panelRow) throw new DomainError("PANEL_NOT_FOUND", "Panel not found", 404);
|
|
5139
|
-
const
|
|
5669
|
+
const definition = (await this.effectiveWebPanelDefinitions(panelRow.worktreeId)).find((candidate) => candidate.id === panelRow.definitionId);
|
|
5670
|
+
const permissionsGranted = definition ? await this.webPanelPermissionsGranted(panelRow.worktreeId, definition) : false;
|
|
5671
|
+
const panel = mapWebPanel(panelRow, definition?.permissions ?? [], permissionsGranted);
|
|
5140
5672
|
const worktree = await this.getWorktree(panel.worktreeId);
|
|
5141
5673
|
const project = await this.getProject(worktree.projectId);
|
|
5142
5674
|
return {
|
|
@@ -5164,6 +5696,19 @@ var TreeportService = class {
|
|
|
5164
5696
|
const worktree = await this.getWorktree(context.panel.worktreeId);
|
|
5165
5697
|
return this.deps.git.worktreeDiff(worktree.path, context.project.defaultBranch);
|
|
5166
5698
|
}
|
|
5699
|
+
async getBrowserPanelListeners(panelId) {
|
|
5700
|
+
const panel = await this.getBrowserPanel(panelId);
|
|
5701
|
+
const worktree = await this.getWorktree(panel.worktreeId);
|
|
5702
|
+
const panes = await this.deps.tmux.listPaneProcesses(worktree.tmuxSocketName, worktree.id);
|
|
5703
|
+
return this.networkListeners.listeners({
|
|
5704
|
+
worktreePath: worktree.path,
|
|
5705
|
+
panes
|
|
5706
|
+
});
|
|
5707
|
+
}
|
|
5708
|
+
async getPanelListeners(panelId) {
|
|
5709
|
+
const [browserPanel] = await this.deps.database.db.select({ id: browserPanels.id }).from(browserPanels).where(eq(browserPanels.id, panelId)).limit(1);
|
|
5710
|
+
return browserPanel ? this.getBrowserPanelListeners(panelId) : this.getWebPanelListeners(panelId);
|
|
5711
|
+
}
|
|
5167
5712
|
async getWebPanelListeners(panelId) {
|
|
5168
5713
|
const context = await this.getWebPanelContext(panelId);
|
|
5169
5714
|
const worktree = await this.getWorktree(context.panel.worktreeId);
|
|
@@ -5214,17 +5759,30 @@ var TreeportService = class {
|
|
|
5214
5759
|
if (!panel) throw new DomainError("PANEL_NOT_FOUND", "Panel not found", 404);
|
|
5215
5760
|
const definition = (await this.effectiveWebPanelDefinitions(panel.worktreeId)).find((candidate) => candidate.id === panel.definitionId);
|
|
5216
5761
|
if (!definition) throw new DomainError("WEB_PANEL_DEFINITION_NOT_FOUND", "The definition for this panel is unavailable", 404);
|
|
5762
|
+
await this.requireWebPanelPermissions(panel.worktreeId, definition);
|
|
5217
5763
|
const encodedPanelId = encodeURIComponent(panelId);
|
|
5218
5764
|
return this.webPanelRuntime.resolve(definition, requestedPath, `/api/web-panels/${encodedPanelId}/assets/`);
|
|
5219
5765
|
}
|
|
5766
|
+
async listBrowserPanels() {
|
|
5767
|
+
return this.deps.database.db.select().from(browserPanels).orderBy(asc(browserPanels.createdAt), asc(browserPanels.id)).then((rows) => rows.map(mapBrowserPanel));
|
|
5768
|
+
}
|
|
5220
5769
|
async listWebPanels() {
|
|
5221
|
-
|
|
5770
|
+
const rows = await this.deps.database.db.select().from(webPanels).orderBy(asc(webPanels.createdAt), asc(webPanels.id));
|
|
5771
|
+
return Promise.all(rows.map(async (row) => {
|
|
5772
|
+
const definition = (await this.effectiveWebPanelDefinitions(row.worktreeId).catch(() => [])).find((candidate) => candidate.id === row.definitionId);
|
|
5773
|
+
return mapWebPanel(row, definition?.permissions ?? [], definition ? await this.webPanelPermissionsGranted(row.worktreeId, definition) : false);
|
|
5774
|
+
}));
|
|
5222
5775
|
}
|
|
5223
5776
|
async getWorktree(worktreeId) {
|
|
5224
5777
|
const worktree = await this.storedWorktree(worktreeId);
|
|
5225
5778
|
if (!worktree) throw new DomainError("WORKTREE_NOT_FOUND", "Tree not found", 404);
|
|
5226
5779
|
return worktree;
|
|
5227
5780
|
}
|
|
5781
|
+
async getWorktreeContext(worktreeId) {
|
|
5782
|
+
const [row] = await this.deps.database.db.select({ treeContextJson: worktrees.treeContextJson }).from(worktrees).where(eq(worktrees.id, worktreeId)).limit(1);
|
|
5783
|
+
if (!row) throw new DomainError("WORKTREE_NOT_FOUND", "Tree not found", 404);
|
|
5784
|
+
return treeContextValuesSchema.parse(JSON.parse(row.treeContextJson));
|
|
5785
|
+
}
|
|
5228
5786
|
async requestWorkspaceOpen(worktreeId, sourceTerminalId) {
|
|
5229
5787
|
const worktree = await this.getWorktree(worktreeId);
|
|
5230
5788
|
await this.requireOpenProject(worktree.projectId);
|
|
@@ -5901,7 +6459,7 @@ var TreeportService = class {
|
|
|
5901
6459
|
if (filters.projectId) await this.requireOpenProject(filters.projectId);
|
|
5902
6460
|
return (await this.deps.database.db.select().from(operations).where(and(or(eq(operations.status, "pending"), eq(operations.status, "running")), ...filters.projectId ? [eq(operations.projectId, filters.projectId)] : [], ...filters.kind ? [eq(operations.kind, filters.kind)] : [])).orderBy(asc(operations.createdAt), asc(operations.id))).map(mapOperation);
|
|
5903
6461
|
}
|
|
5904
|
-
async beginCreateWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId) {
|
|
6462
|
+
async beginCreateWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId, treeContext) {
|
|
5905
6463
|
if ((await this.requireOpenProject(projectId)).kind === "folder") throw new DomainError("PROJECT_HAS_NO_GIT_REPOSITORY", "Linked worktrees require a Git repository project", 409);
|
|
5906
6464
|
let name;
|
|
5907
6465
|
try {
|
|
@@ -5918,6 +6476,7 @@ var TreeportService = class {
|
|
|
5918
6476
|
};
|
|
5919
6477
|
if (initialTerminal) request.initialTerminal = initialTerminal;
|
|
5920
6478
|
if (sourceWorktreeId) request.sourceWorktreeId = sourceWorktreeId;
|
|
6479
|
+
if (treeContext && Object.keys(treeContext).length > 0) request.context = treeContext;
|
|
5921
6480
|
await this.deps.database.db.run(sql`
|
|
5922
6481
|
INSERT INTO operations(
|
|
5923
6482
|
id,kind,project_id,worktree_id,status,request_json,result_json,error,
|
|
@@ -5932,16 +6491,16 @@ var TreeportService = class {
|
|
|
5932
6491
|
projectId,
|
|
5933
6492
|
operationId
|
|
5934
6493
|
});
|
|
5935
|
-
this.worktreeMutations.enqueue(projectId, () => this.executeCreateOperation(operationId, projectId, name, base, initialTerminal, sourceWorktreeId)).catch(() => void 0);
|
|
6494
|
+
this.worktreeMutations.enqueue(projectId, () => this.executeCreateOperation(operationId, projectId, name, base, initialTerminal, sourceWorktreeId, treeContext)).catch(() => void 0);
|
|
5936
6495
|
return operation;
|
|
5937
6496
|
}
|
|
5938
|
-
async executeCreateOperation(operationId, projectId, inputName, base, initialTerminal, sourceWorktreeId) {
|
|
6497
|
+
async executeCreateOperation(operationId, projectId, inputName, base, initialTerminal, sourceWorktreeId, treeContext) {
|
|
5939
6498
|
await this.deps.database.db.run(sql`
|
|
5940
6499
|
UPDATE operations SET status='running',updated_at=${now()}
|
|
5941
6500
|
WHERE id=${operationId} AND status='pending'
|
|
5942
6501
|
`);
|
|
5943
6502
|
try {
|
|
5944
|
-
const result = await this.executeCreateWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId);
|
|
6503
|
+
const result = await this.executeCreateWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId, treeContext);
|
|
5945
6504
|
const timestamp = now();
|
|
5946
6505
|
await this.deps.database.db.run(sql`
|
|
5947
6506
|
UPDATE operations
|
|
@@ -5972,12 +6531,13 @@ var TreeportService = class {
|
|
|
5972
6531
|
});
|
|
5973
6532
|
}
|
|
5974
6533
|
}
|
|
5975
|
-
async createWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId) {
|
|
6534
|
+
async createWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId, treeContext) {
|
|
5976
6535
|
if ((await this.requireOpenProject(projectId)).kind === "folder") throw new DomainError("PROJECT_HAS_NO_GIT_REPOSITORY", "Linked worktrees require a Git repository project", 409);
|
|
5977
6536
|
if (this.projectLocks.has(projectId) && !this.worktreeMutations.has(projectId)) throw new DomainError("PROJECT_BUSY", "Project is already being modified", 409);
|
|
5978
|
-
return this.worktreeMutations.enqueue(projectId, () => this.executeCreateWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId));
|
|
6537
|
+
return this.worktreeMutations.enqueue(projectId, () => this.executeCreateWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId, treeContext));
|
|
5979
6538
|
}
|
|
5980
|
-
async executeCreateWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId) {
|
|
6539
|
+
async executeCreateWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId, treeContext) {
|
|
6540
|
+
const contextValues = treeContextValuesSchema.parse(treeContext ?? {});
|
|
5981
6541
|
await this.requireOpenProject(projectId);
|
|
5982
6542
|
if (this.projectLocks.has(projectId)) throw new DomainError("PROJECT_BUSY", "Project is already being modified", 409);
|
|
5983
6543
|
this.projectLocks.add(projectId);
|
|
@@ -6027,7 +6587,8 @@ var TreeportService = class {
|
|
|
6027
6587
|
await this.importWorktrees(project.id, project.repositoryPath, project.mainWorktreePath, true);
|
|
6028
6588
|
await this.deps.database.db.run(sql`
|
|
6029
6589
|
UPDATE worktrees
|
|
6030
|
-
SET managed_wrapper_path=${wrapperCreated ? wrapperPath : null}
|
|
6590
|
+
SET managed_wrapper_path=${wrapperCreated ? wrapperPath : null},
|
|
6591
|
+
tree_context_json=${JSON.stringify(contextValues)}
|
|
6031
6592
|
WHERE path=${worktreePath}
|
|
6032
6593
|
`);
|
|
6033
6594
|
const [worktreeRow] = await this.deps.database.db.select({
|
|
@@ -6047,6 +6608,7 @@ var TreeportService = class {
|
|
|
6047
6608
|
let setupError = null;
|
|
6048
6609
|
if (initialTerminal) {
|
|
6049
6610
|
const launchOptions = {};
|
|
6611
|
+
if (initialTerminal.initialTitle) launchOptions.initialTitle = initialTerminal.initialTitle;
|
|
6050
6612
|
if (initialTerminal.returnToShell) launchOptions.returnToShell = true;
|
|
6051
6613
|
if (initialTerminal.initialSize) launchOptions.initialSize = initialTerminal.initialSize;
|
|
6052
6614
|
const initialTerminalCreation = this.executeCreateTerminal(worktree.id, initialTerminal.name, initialTerminal.argv, launchOptions);
|
|
@@ -6172,6 +6734,7 @@ var TreeportService = class {
|
|
|
6172
6734
|
TREEPORT_TERMINAL_ID: terminalId
|
|
6173
6735
|
}
|
|
6174
6736
|
};
|
|
6737
|
+
if (options?.initialTitle) session.initialTitle = options.initialTitle;
|
|
6175
6738
|
if (options?.returnToShell && !interactiveShell) session.fallbackArgv = [this.deps.config.shell, "-l"];
|
|
6176
6739
|
if (options?.closeOnSuccess) session.closeOnSuccess = true;
|
|
6177
6740
|
if (options?.initialSize) session.initialSize = options.initialSize;
|
|
@@ -6706,7 +7269,11 @@ var TreeportService = class {
|
|
|
6706
7269
|
return terminated;
|
|
6707
7270
|
}
|
|
6708
7271
|
async drainMutations() {
|
|
6709
|
-
await Promise.all([
|
|
7272
|
+
await Promise.all([
|
|
7273
|
+
this.worktreeMutations.drain(),
|
|
7274
|
+
this.terminalMutations.drain(),
|
|
7275
|
+
this.treeFileMutations.drain()
|
|
7276
|
+
]);
|
|
6710
7277
|
}
|
|
6711
7278
|
async reconcile() {
|
|
6712
7279
|
const availableProjects = /* @__PURE__ */ new Set();
|
|
@@ -7831,52 +8398,217 @@ var TerminalMetadataManager = class {
|
|
|
7831
8398
|
}
|
|
7832
8399
|
};
|
|
7833
8400
|
//#endregion
|
|
7834
|
-
//#region src/server/
|
|
7835
|
-
const
|
|
7836
|
-
|
|
7837
|
-
|
|
7838
|
-
|
|
7839
|
-
|
|
7840
|
-
|
|
7841
|
-
|
|
7842
|
-
|
|
8401
|
+
//#region src/server/request-security.ts
|
|
8402
|
+
const MAX_HOST_BYTES = 512;
|
|
8403
|
+
const MAX_LOGIN_BYTES = 320;
|
|
8404
|
+
const MAX_NAME_BYTES = 512;
|
|
8405
|
+
const MAX_PROFILE_PICTURE_BYTES = 2048;
|
|
8406
|
+
const UNSAFE_METHODS = /* @__PURE__ */ new Set([
|
|
8407
|
+
"POST",
|
|
8408
|
+
"PUT",
|
|
8409
|
+
"PATCH",
|
|
8410
|
+
"DELETE"
|
|
7843
8411
|
]);
|
|
7844
|
-
|
|
7845
|
-
|
|
7846
|
-
|
|
7847
|
-
|
|
7848
|
-
|
|
7849
|
-
|
|
7850
|
-
|
|
7851
|
-
|
|
7852
|
-
|
|
7853
|
-
|
|
7854
|
-
|
|
7855
|
-
|
|
7856
|
-
|
|
7857
|
-
|
|
7858
|
-
]
|
|
7859
|
-
|
|
7860
|
-
|
|
7861
|
-
|
|
7862
|
-
|
|
7863
|
-
const
|
|
7864
|
-
|
|
7865
|
-
|
|
7866
|
-
|
|
7867
|
-
|
|
7868
|
-
|
|
7869
|
-
|
|
7870
|
-
|
|
7871
|
-
|
|
7872
|
-
|
|
7873
|
-
|
|
7874
|
-
|
|
7875
|
-
|
|
7876
|
-
|
|
7877
|
-
|
|
7878
|
-
|
|
7879
|
-
|
|
8412
|
+
function denied(status, code, message) {
|
|
8413
|
+
return {
|
|
8414
|
+
allowed: false,
|
|
8415
|
+
principal: null,
|
|
8416
|
+
effectiveOrigin: null,
|
|
8417
|
+
status,
|
|
8418
|
+
code,
|
|
8419
|
+
message
|
|
8420
|
+
};
|
|
8421
|
+
}
|
|
8422
|
+
function headerValues(request, name) {
|
|
8423
|
+
const values = [];
|
|
8424
|
+
for (let index = 0; index < request.rawHeaders.length; index += 2) if (request.rawHeaders[index]?.toLowerCase() === name) values.push(request.rawHeaders[index + 1] ?? "");
|
|
8425
|
+
if (values.length > 0) return values;
|
|
8426
|
+
const value = request.headers[name];
|
|
8427
|
+
if (value === void 0) return [];
|
|
8428
|
+
return Array.isArray(value) ? value : [value];
|
|
8429
|
+
}
|
|
8430
|
+
function singleHeader(request, name) {
|
|
8431
|
+
const values = headerValues(request, name);
|
|
8432
|
+
return {
|
|
8433
|
+
present: values.length > 0,
|
|
8434
|
+
valid: values.length <= 1,
|
|
8435
|
+
value: values.length === 1 ? values[0] ?? null : null
|
|
8436
|
+
};
|
|
8437
|
+
}
|
|
8438
|
+
function hasControlCharacters(value) {
|
|
8439
|
+
return [...value].some((character) => {
|
|
8440
|
+
const code = character.codePointAt(0) ?? 0;
|
|
8441
|
+
return code <= 31 || code === 127;
|
|
8442
|
+
});
|
|
8443
|
+
}
|
|
8444
|
+
function parseHost(value) {
|
|
8445
|
+
if (value.length === 0 || Buffer.byteLength(value) > MAX_HOST_BYTES || hasControlCharacters(value) || /[\s,/@?#\\]/u.test(value)) return null;
|
|
8446
|
+
const candidate = `http://${value}`;
|
|
8447
|
+
if (!URL.canParse(candidate)) return null;
|
|
8448
|
+
const url = new URL(candidate);
|
|
8449
|
+
if (!url.host || url.pathname !== "/") return null;
|
|
8450
|
+
return {
|
|
8451
|
+
host: url.host,
|
|
8452
|
+
hostname: url.hostname.toLowerCase()
|
|
8453
|
+
};
|
|
8454
|
+
}
|
|
8455
|
+
function isLoopbackHostname(hostname) {
|
|
8456
|
+
return [
|
|
8457
|
+
"127.0.0.1",
|
|
8458
|
+
"localhost",
|
|
8459
|
+
"[::1]",
|
|
8460
|
+
"::1"
|
|
8461
|
+
].includes(hostname);
|
|
8462
|
+
}
|
|
8463
|
+
function isLoopbackAddress(address) {
|
|
8464
|
+
if (!address) return false;
|
|
8465
|
+
const normalized = address.toLowerCase();
|
|
8466
|
+
if (normalized === "::1") return true;
|
|
8467
|
+
const octets = (normalized.startsWith("::ffff:") ? normalized.slice(7) : normalized).split(".");
|
|
8468
|
+
return octets.length === 4 && octets[0] === "127" && octets.every((octet) => /^\d{1,3}$/u.test(octet) && Number(octet) <= 255);
|
|
8469
|
+
}
|
|
8470
|
+
function validIdentityValue(value, maximumBytes) {
|
|
8471
|
+
return value.length > 0 && value === value.trim() && Buffer.byteLength(value) <= maximumBytes && !hasControlCharacters(value);
|
|
8472
|
+
}
|
|
8473
|
+
function effectiveOriginFor(request, source, incomingHost) {
|
|
8474
|
+
if (source === "local") return `http://${incomingHost.host}`;
|
|
8475
|
+
const forwardedHostHeader = singleHeader(request, "x-forwarded-host");
|
|
8476
|
+
const forwardedProtocolHeader = singleHeader(request, "x-forwarded-proto");
|
|
8477
|
+
if (!forwardedHostHeader.valid || !forwardedHostHeader.present || !forwardedProtocolHeader.valid || !forwardedProtocolHeader.present) return null;
|
|
8478
|
+
const forwardedHost = parseHost(forwardedHostHeader.value ?? "");
|
|
8479
|
+
if (!forwardedHost || forwardedProtocolHeader.value?.toLowerCase() !== "https") return null;
|
|
8480
|
+
return `https://${forwardedHost.host}`;
|
|
8481
|
+
}
|
|
8482
|
+
function allowsOpaqueWebPanelOrigin(request, socketUpgrade) {
|
|
8483
|
+
if (!["GET", "HEAD"].includes(request.method?.toUpperCase() ?? "")) return false;
|
|
8484
|
+
const pathname = new URL(request.url ?? "/", "http://treeport.local").pathname;
|
|
8485
|
+
if (socketUpgrade) return /^\/api\/web-panel-dev\/[a-f0-9]{24}\/@vite-hmr$/u.test(pathname);
|
|
8486
|
+
return /^\/api\/web-panels\/panel_[a-f0-9]{32}\/assets(?:\/|$)/u.test(pathname) || /^\/api\/web-panel-dev\/[a-f0-9]{24}\//u.test(pathname);
|
|
8487
|
+
}
|
|
8488
|
+
function originIsAllowed(request, effectiveOrigin, socketUpgrade) {
|
|
8489
|
+
const originHeader = singleHeader(request, "origin");
|
|
8490
|
+
if (!originHeader.valid) return false;
|
|
8491
|
+
if (originHeader.present) {
|
|
8492
|
+
const value = originHeader.value ?? "";
|
|
8493
|
+
if (value === "null") {
|
|
8494
|
+
if (!allowsOpaqueWebPanelOrigin(request, socketUpgrade)) return false;
|
|
8495
|
+
} else {
|
|
8496
|
+
if (!URL.canParse(value)) return false;
|
|
8497
|
+
const parsed = new URL(value);
|
|
8498
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.origin !== value || parsed.origin !== effectiveOrigin) return false;
|
|
8499
|
+
}
|
|
8500
|
+
}
|
|
8501
|
+
const fetchSiteHeader = singleHeader(request, "sec-fetch-site");
|
|
8502
|
+
if (!fetchSiteHeader.valid) return false;
|
|
8503
|
+
if ((UNSAFE_METHODS.has(request.method?.toUpperCase() ?? "") || socketUpgrade) && fetchSiteHeader.value?.toLowerCase() === "cross-site") return false;
|
|
8504
|
+
return true;
|
|
8505
|
+
}
|
|
8506
|
+
function authorizeRequest(request, options = {}) {
|
|
8507
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) return denied(401, "AUTHENTICATION_REQUIRED", "Treeport accepts remote requests only through Tailscale Serve.");
|
|
8508
|
+
const hostHeader = singleHeader(request, "host");
|
|
8509
|
+
const incomingHost = hostHeader.valid && hostHeader.value ? parseHost(hostHeader.value.toLowerCase()) : null;
|
|
8510
|
+
if (!incomingHost) return denied(400, "INVALID_REQUEST_HOST", "The request host is invalid.");
|
|
8511
|
+
const loginHeader = singleHeader(request, "tailscale-user-login");
|
|
8512
|
+
const nameHeader = singleHeader(request, "tailscale-user-name");
|
|
8513
|
+
const profilePictureHeader = singleHeader(request, "tailscale-user-profile-pic");
|
|
8514
|
+
const hasTailscaleIdentity = loginHeader.present || nameHeader.present || profilePictureHeader.present;
|
|
8515
|
+
const headersHaveSingleValues = loginHeader.valid && nameHeader.valid && profilePictureHeader.valid;
|
|
8516
|
+
let principal;
|
|
8517
|
+
if (hasTailscaleIdentity) {
|
|
8518
|
+
const login = loginHeader.value ?? "";
|
|
8519
|
+
const name = nameHeader.value === "" ? null : nameHeader.value;
|
|
8520
|
+
const profilePicture = profilePictureHeader.value === "" ? null : profilePictureHeader.value;
|
|
8521
|
+
const validProfilePicture = profilePicture === null || validIdentityValue(profilePicture, MAX_PROFILE_PICTURE_BYTES) && URL.canParse(profilePicture) && ["http:", "https:"].includes(new URL(profilePicture).protocol);
|
|
8522
|
+
if (!headersHaveSingleValues || !loginHeader.present || !validIdentityValue(login, MAX_LOGIN_BYTES) || name !== null && !validIdentityValue(name, MAX_NAME_BYTES) || !validProfilePicture) return denied(401, "INVALID_TAILSCALE_IDENTITY", "The Tailscale user identity is invalid.");
|
|
8523
|
+
principal = {
|
|
8524
|
+
source: "tailscale",
|
|
8525
|
+
login,
|
|
8526
|
+
name,
|
|
8527
|
+
profilePicture
|
|
8528
|
+
};
|
|
8529
|
+
} else {
|
|
8530
|
+
if (!isLoopbackHostname(incomingHost.hostname)) return denied(401, "AUTHENTICATION_REQUIRED", "Treeport accepts remote requests only through Tailscale Serve.");
|
|
8531
|
+
principal = {
|
|
8532
|
+
source: "local",
|
|
8533
|
+
login: null,
|
|
8534
|
+
name: null,
|
|
8535
|
+
profilePicture: null
|
|
8536
|
+
};
|
|
8537
|
+
}
|
|
8538
|
+
const effectiveOrigin = effectiveOriginFor(request, principal.source, incomingHost);
|
|
8539
|
+
if (!effectiveOrigin) return denied(400, "INVALID_FORWARDED_ORIGIN", "The forwarded request origin is invalid.");
|
|
8540
|
+
if (!originIsAllowed(request, effectiveOrigin, options.socketUpgrade === true)) return denied(403, "INVALID_ORIGIN", "The request origin is not allowed.");
|
|
8541
|
+
return {
|
|
8542
|
+
allowed: true,
|
|
8543
|
+
principal,
|
|
8544
|
+
effectiveOrigin,
|
|
8545
|
+
status: 200,
|
|
8546
|
+
code: null,
|
|
8547
|
+
message: null
|
|
8548
|
+
};
|
|
8549
|
+
}
|
|
8550
|
+
function rejectHttpRequest(request, response, decision) {
|
|
8551
|
+
const apiRequest = request.url?.startsWith("/api/") === true;
|
|
8552
|
+
const body = apiRequest ? `${JSON.stringify({ error: {
|
|
8553
|
+
code: decision.code,
|
|
8554
|
+
message: decision.message
|
|
8555
|
+
} })}\n` : `${decision.message ?? "The request was rejected."}\n`;
|
|
8556
|
+
response.statusCode = decision.status;
|
|
8557
|
+
response.setHeader("cache-control", "no-store");
|
|
8558
|
+
response.setHeader("content-type", apiRequest ? "application/json; charset=utf-8" : "text/plain; charset=utf-8");
|
|
8559
|
+
response.end(request.method === "HEAD" ? void 0 : body);
|
|
8560
|
+
}
|
|
8561
|
+
//#endregion
|
|
8562
|
+
//#region src/server/app.ts
|
|
8563
|
+
const UPLOAD_MIME_EXTENSIONS = /* @__PURE__ */ new Map([
|
|
8564
|
+
["application/pdf", "pdf"],
|
|
8565
|
+
["image/gif", "gif"],
|
|
8566
|
+
["image/jpeg", "jpg"],
|
|
8567
|
+
["image/png", "png"],
|
|
8568
|
+
["image/svg+xml", "svg"],
|
|
8569
|
+
["image/webp", "webp"],
|
|
8570
|
+
["text/plain", "txt"]
|
|
8571
|
+
]);
|
|
8572
|
+
const UPLOAD_RETENTION_MS = 1440 * 6e4;
|
|
8573
|
+
const UPLOAD_DIRECTORY_MAX_BYTES = 512 * 1024 * 1024;
|
|
8574
|
+
const terminalPresetDefinitionsQuerySchema = z.object({
|
|
8575
|
+
projectId: z.string().optional(),
|
|
8576
|
+
worktreeId: z.string().optional()
|
|
8577
|
+
});
|
|
8578
|
+
const treeContextFieldsQuerySchema = z.object({ projectId: z.string().min(1) });
|
|
8579
|
+
const operationQuerySchema = z.object({
|
|
8580
|
+
kind: z.enum([
|
|
8581
|
+
"create",
|
|
8582
|
+
"finish",
|
|
8583
|
+
"discard",
|
|
8584
|
+
"project_cleanup",
|
|
8585
|
+
"remove",
|
|
8586
|
+
"external_remove"
|
|
8587
|
+
]).optional(),
|
|
8588
|
+
projectId: z.string().optional()
|
|
8589
|
+
});
|
|
8590
|
+
const deletePanelQuerySchema = z.object({
|
|
8591
|
+
discardStoredData: z.string().optional(),
|
|
8592
|
+
force: z.string().optional()
|
|
8593
|
+
});
|
|
8594
|
+
async function pruneTerminalUploads(directory, preservePath) {
|
|
8595
|
+
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
8596
|
+
const files = (await Promise.all(entries.filter((entry) => entry.isFile() && entry.name.startsWith("treeport-upload-")).map(async (entry) => {
|
|
8597
|
+
const filePath = path.join(directory, entry.name);
|
|
8598
|
+
return fs.stat(filePath).then((stat) => ({
|
|
8599
|
+
path: filePath,
|
|
8600
|
+
size: stat.size,
|
|
8601
|
+
mtimeMs: stat.mtimeMs
|
|
8602
|
+
})).catch(() => null);
|
|
8603
|
+
}))).filter((file) => file !== null).sort((left, right) => {
|
|
8604
|
+
if (left.path === preservePath) return -1;
|
|
8605
|
+
if (right.path === preservePath) return 1;
|
|
8606
|
+
return right.mtimeMs - left.mtimeMs;
|
|
8607
|
+
});
|
|
8608
|
+
const expiredBefore = Date.now() - UPLOAD_RETENTION_MS;
|
|
8609
|
+
let retainedBytes = 0;
|
|
8610
|
+
for (const file of files) {
|
|
8611
|
+
const expired = file.mtimeMs < expiredBefore;
|
|
7880
8612
|
const overQuota = retainedBytes + file.size > UPLOAD_DIRECTORY_MAX_BYTES;
|
|
7881
8613
|
if (file.path !== preservePath && (expired || overQuota)) {
|
|
7882
8614
|
await fs.rm(file.path, { force: true });
|
|
@@ -7895,7 +8627,13 @@ function queryInput(schema) {
|
|
|
7895
8627
|
if (!result.success) throw new DomainError("VALIDATION_ERROR", "Request validation failed", 400, z.flattenError(result.error));
|
|
7896
8628
|
});
|
|
7897
8629
|
}
|
|
7898
|
-
function
|
|
8630
|
+
function createTreeFilesApi(service) {
|
|
8631
|
+
return new Hono().get("/api/panels/:panelId/files", async (context) => context.json(await service.listTreeFiles(context.req.param("panelId")))).post("/api/panels/:panelId/files/read", jsonInput(readTreeFileSchema), async (context) => {
|
|
8632
|
+
const body = context.req.valid("json");
|
|
8633
|
+
return context.json(await service.readTreeFile(context.req.param("panelId"), body.path));
|
|
8634
|
+
}).put("/api/panels/:panelId/files", jsonInput(writeTreeFileSchema), async (context) => context.json(await service.writeTreeFile(context.req.param("panelId"), context.req.valid("json"))));
|
|
8635
|
+
}
|
|
8636
|
+
function createApp({ service, config, tmux, applicationUpdate, terminalMetadata, browserSessions, webDist }) {
|
|
7899
8637
|
const app = new Hono();
|
|
7900
8638
|
app.use("/api/*", requestId({
|
|
7901
8639
|
limitLength: 128,
|
|
@@ -7935,10 +8673,52 @@ function createApp({ service, config, tmux, applicationUpdate, terminalMetadata,
|
|
|
7935
8673
|
details: { requestId: requestIdentifier }
|
|
7936
8674
|
} }, 500);
|
|
7937
8675
|
});
|
|
8676
|
+
const browserApi = new Hono().get("/api/browser/status", async (context) => {
|
|
8677
|
+
if (!browserSessions) throw new DomainError("BROWSER_UNAVAILABLE", "Hosted browser service is unavailable", 503);
|
|
8678
|
+
return context.json(await browserSessions.status());
|
|
8679
|
+
}).post("/api/browser/install", async (context) => {
|
|
8680
|
+
if (!browserSessions) throw new DomainError("BROWSER_UNAVAILABLE", "Hosted browser service is unavailable", 503);
|
|
8681
|
+
return context.json({ message: await browserSessions.install() });
|
|
8682
|
+
}).delete("/api/browser/install", async (context) => {
|
|
8683
|
+
if (!browserSessions) throw new DomainError("BROWSER_UNAVAILABLE", "Hosted browser service is unavailable", 503);
|
|
8684
|
+
await browserSessions.remove();
|
|
8685
|
+
return context.json({ ok: true });
|
|
8686
|
+
}).put("/api/worktrees/:worktreeId/web-panel-definitions/:definitionId/permission-grant", jsonInput(updateWebPanelPermissionGrantSchema), async (context) => {
|
|
8687
|
+
const body = context.req.valid("json");
|
|
8688
|
+
const definition = await service.setWebPanelPermissionGrant(context.req.param("worktreeId"), context.req.param("definitionId"), body.granted, body.permissions);
|
|
8689
|
+
return context.json({ definition });
|
|
8690
|
+
}).post("/api/panels/:panelId/browser-agent", jsonInput(browserAgentCommandSchema), async (context) => {
|
|
8691
|
+
if (!browserSessions) throw new DomainError("BROWSER_UNAVAILABLE", "Hosted browser service is unavailable", 503);
|
|
8692
|
+
const panelId = context.req.param("panelId");
|
|
8693
|
+
const input = context.req.valid("json");
|
|
8694
|
+
return context.json({ output: await browserSessions.agentCommand(panelId, input).catch((cause) => {
|
|
8695
|
+
if (cause instanceof DomainError) throw cause;
|
|
8696
|
+
throw new DomainError("BROWSER_COMMAND_FAILED", cause instanceof Error ? cause.message : "The Browser command failed.", 409, {
|
|
8697
|
+
command: input.command,
|
|
8698
|
+
recovery: input.command === "snapshot" ? `Retry \`treeport browser snapshot --panel ${panelId}\`.` : input.command === "screenshot" ? `Open Browser ${panelId} in Treeport, then retry the screenshot.` : `Run \`treeport browser snapshot --panel ${panelId}\`, then retry this Browser command.`
|
|
8699
|
+
});
|
|
8700
|
+
}) });
|
|
8701
|
+
}).post("/api/panels/:panelId/browser-owner-ticket", jsonInput(browserOwnerTicketRequestSchema), async (context) => {
|
|
8702
|
+
if (!browserSessions) throw new DomainError("BROWSER_UNAVAILABLE", "Browser service is unavailable", 503);
|
|
8703
|
+
const remoteAddress = getConnInfo(context).remote.address;
|
|
8704
|
+
const proxiedIdentity = [
|
|
8705
|
+
"tailscale-user-login",
|
|
8706
|
+
"tailscale-user-name",
|
|
8707
|
+
"tailscale-user-profile-pic"
|
|
8708
|
+
].some((name) => context.req.header(name) !== void 0);
|
|
8709
|
+
if (!isLoopbackAddress(remoteAddress) || proxiedIdentity) throw new DomainError("BROWSER_LOCAL_OWNER_REQUIRED", "Only a local desktop app can own this Browser.", 403);
|
|
8710
|
+
return context.json(await browserSessions.issueOwnerTicket(context.req.param("panelId"), context.req.valid("json").clientId));
|
|
8711
|
+
}).post("/api/panels/:panelId/browser-ticket", jsonInput(browserTicketRequestSchema), async (context) => {
|
|
8712
|
+
if (!browserSessions) throw new DomainError("BROWSER_UNAVAILABLE", "Hosted browser service is unavailable", 503);
|
|
8713
|
+
const body = context.req.valid("json");
|
|
8714
|
+
return context.json({ ticket: await browserSessions.issueTicket(context.req.param("panelId"), body.clientId, body.visible) });
|
|
8715
|
+
});
|
|
8716
|
+
app.route("/", browserApi);
|
|
8717
|
+
app.route("/", createTreeFilesApi(service));
|
|
7938
8718
|
const api = new Hono().get("/api/health", (context) => context.json({
|
|
7939
8719
|
ok: true,
|
|
7940
8720
|
version: config.appVersion ?? "development",
|
|
7941
|
-
protocolVersion:
|
|
8721
|
+
protocolVersion: 3,
|
|
7942
8722
|
hostname: os.hostname(),
|
|
7943
8723
|
pid: process.pid,
|
|
7944
8724
|
instanceId: config.instanceId ?? null,
|
|
@@ -7951,7 +8731,7 @@ function createApp({ service, config, tmux, applicationUpdate, terminalMetadata,
|
|
|
7951
8731
|
}).post("/api/update", async (context) => {
|
|
7952
8732
|
context.header("Cache-Control", "no-store");
|
|
7953
8733
|
return context.json(await applicationUpdate.start(), 202);
|
|
7954
|
-
}).get("/api/terminal-presets", async (context) => context.json({ presets: await service.listTerminalPresets() })).get("/api/terminal-preset-definitions", queryInput(terminalPresetDefinitionsQuerySchema), async (context) => context.json(await service.listTerminalPresetDefinitions(context.req.valid("query")))).post("/api/terminal-presets", jsonInput(createTerminalPresetSchema), async (context) => {
|
|
8734
|
+
}).get("/api/terminal-presets", async (context) => context.json({ presets: await service.listTerminalPresets() })).get("/api/tree-context-fields", queryInput(treeContextFieldsQuerySchema), async (context) => context.json(await service.listTreeContextFields(context.req.valid("query").projectId))).get("/api/terminal-preset-definitions", queryInput(terminalPresetDefinitionsQuerySchema), async (context) => context.json(await service.listTerminalPresetDefinitions(context.req.valid("query")))).post("/api/terminal-presets", jsonInput(createTerminalPresetSchema), async (context) => {
|
|
7955
8735
|
const body = context.req.valid("json");
|
|
7956
8736
|
return context.json({ preset: await service.createTerminalPreset(body) }, 201);
|
|
7957
8737
|
}).patch("/api/terminal-presets/:presetId", jsonInput(updateTerminalPresetSchema), async (context) => {
|
|
@@ -8003,19 +8783,23 @@ function createApp({ service, config, tmux, applicationUpdate, terminalMetadata,
|
|
|
8003
8783
|
let initialTerminal;
|
|
8004
8784
|
if (body.initialTerminal) {
|
|
8005
8785
|
initialTerminal = { name: body.initialTerminal.name };
|
|
8786
|
+
if (body.initialTerminal.initialTitle) initialTerminal.initialTitle = body.initialTerminal.initialTitle;
|
|
8006
8787
|
if (body.initialTerminal.argv) initialTerminal.argv = body.initialTerminal.argv;
|
|
8007
8788
|
if (body.initialTerminal.returnToShell) initialTerminal.returnToShell = true;
|
|
8008
8789
|
if (body.initialTerminal.initialSize) initialTerminal.initialSize = body.initialTerminal.initialSize;
|
|
8009
8790
|
}
|
|
8010
|
-
return context.json({ operation: await service.beginCreateWorktree(context.req.param("projectId"), body.name, body.base, initialTerminal, body.sourceWorktreeId) }, 202);
|
|
8791
|
+
return context.json({ operation: await service.beginCreateWorktree(context.req.param("projectId"), body.name, body.base, initialTerminal, body.sourceWorktreeId, body.context) }, 202);
|
|
8011
8792
|
}).get("/api/worktrees/:worktreeId", async (context) => {
|
|
8012
8793
|
const worktreeId = context.req.param("worktreeId");
|
|
8013
8794
|
await service.refreshPr(worktreeId, false);
|
|
8014
8795
|
return context.json({ worktree: await service.getWorktreeSnapshot(worktreeId) });
|
|
8015
|
-
}).post("/api/worktrees/:worktreeId/open", jsonInput(requestWorkspaceOpenSchema), async (context) => {
|
|
8796
|
+
}).get("/api/worktrees/:worktreeId/context", async (context) => context.json({ context: await service.getWorktreeContext(context.req.param("worktreeId")) })).post("/api/worktrees/:worktreeId/open", jsonInput(requestWorkspaceOpenSchema), async (context) => {
|
|
8016
8797
|
await service.requestWorkspaceOpen(context.req.param("worktreeId"), context.req.valid("json").sourceTerminalId);
|
|
8017
8798
|
return context.json({ ok: true });
|
|
8018
|
-
}).
|
|
8799
|
+
}).post("/api/worktrees/:worktreeId/browser-panels", jsonInput(createBrowserPanelSchema), async (context) => {
|
|
8800
|
+
const body = context.req.valid("json");
|
|
8801
|
+
return context.json(await service.openBrowserPanel(context.req.param("worktreeId"), body.url, body.sourceTerminalId ?? null, null), 201);
|
|
8802
|
+
}).post("/api/terminals/:terminalId/browser-panels/open", jsonInput(openBrowserPanelFromTerminalSchema), async (context) => context.json(await service.openBrowserPanelFromTerminal(context.req.param("terminalId"), context.req.valid("json").url), 201)).get("/api/worktrees/:worktreeId/web-panel-definitions", async (context) => context.json({ definitions: await service.listWebPanelDefinitions(context.req.param("worktreeId")) })).post("/api/worktrees/:worktreeId/panels", jsonInput(createWebPanelSchema), async (context) => {
|
|
8019
8803
|
const body = context.req.valid("json");
|
|
8020
8804
|
return context.json({ panel: await service.createWebPanel(context.req.param("worktreeId"), body.definitionId, {
|
|
8021
8805
|
input: body.input ?? null,
|
|
@@ -8027,10 +8811,13 @@ function createApp({ service, config, tmux, applicationUpdate, terminalMetadata,
|
|
|
8027
8811
|
input: body.input ?? null,
|
|
8028
8812
|
cwd: body.launchCwd ?? null
|
|
8029
8813
|
}, body.newInstance ?? false, body.sourceTerminalId ?? null));
|
|
8030
|
-
}).delete("/api/panels/:panelId", queryInput(
|
|
8031
|
-
|
|
8814
|
+
}).delete("/api/panels/:panelId", queryInput(deletePanelQuerySchema), async (context) => {
|
|
8815
|
+
const panelId = context.req.param("panelId");
|
|
8816
|
+
const query = context.req.valid("query");
|
|
8817
|
+
if (await browserSessions?.requestPanelClose(panelId, query.force === "true") === false) throw new DomainError("BROWSER_BEFORE_UNLOAD", "Changes you made may not be saved.", 409);
|
|
8818
|
+
await service.deletePanel(panelId, query.discardStoredData === "true");
|
|
8032
8819
|
return context.json({ ok: true });
|
|
8033
|
-
}).get("/api/panels/:panelId/context", async (context) => context.json({ context: await service.getWebPanelContext(context.req.param("panelId")) })).get("/api/panels/:panelId/diff", async (context) => context.json({ diff: await service.getWebPanelDiff(context.req.param("panelId")) })).get("/api/panels/:panelId/network/listeners", async (context) => context.json({ discovery: await service.
|
|
8820
|
+
}).get("/api/panels/:panelId/context", async (context) => context.json({ context: await service.getWebPanelContext(context.req.param("panelId")) })).get("/api/panels/:panelId/diff", async (context) => context.json({ diff: await service.getWebPanelDiff(context.req.param("panelId")) })).get("/api/panels/:panelId/network/listeners", async (context) => context.json({ discovery: await service.getPanelListeners(context.req.param("panelId")) })).get("/api/panels/:panelId/storage", async (context) => context.json({ hasData: await service.hasWebPanelStorage(context.req.param("panelId")) })).post("/api/panels/:panelId/storage/get", jsonInput(getWebPanelStorageSchema), async (context) => {
|
|
8034
8821
|
const body = context.req.valid("json");
|
|
8035
8822
|
return context.json({ value: await service.getWebPanelStorage(context.req.param("panelId"), body.key) });
|
|
8036
8823
|
}).put("/api/panels/:panelId/storage", jsonInput(setWebPanelStorageSchema), async (context) => {
|
|
@@ -8091,6 +8878,7 @@ function createApp({ service, config, tmux, applicationUpdate, terminalMetadata,
|
|
|
8091
8878
|
}).post("/api/worktrees/:worktreeId/terminals", jsonInput(createTerminalSchema), async (context) => {
|
|
8092
8879
|
const body = context.req.valid("json");
|
|
8093
8880
|
const options = {};
|
|
8881
|
+
if (body.initialTitle) options.initialTitle = body.initialTitle;
|
|
8094
8882
|
if (body.returnToShell) options.returnToShell = true;
|
|
8095
8883
|
if (body.closeOnSuccess) options.closeOnSuccess = true;
|
|
8096
8884
|
if (body.initialSize) options.initialSize = body.initialSize;
|
|
@@ -8445,18 +9233,1879 @@ function createApplicationUpdateManager(config, dependencies = {}) {
|
|
|
8445
9233
|
launchError = launchResult.error instanceof Error ? launchResult.error.message : "Treeport could not start the update process.";
|
|
8446
9234
|
throw new DomainError("APPLICATION_UPDATE_START_FAILED", "Treeport could not start the update process.", 500);
|
|
8447
9235
|
}
|
|
8448
|
-
return status();
|
|
8449
|
-
},
|
|
8450
|
-
dispose() {
|
|
8451
|
-
disposed = true;
|
|
8452
|
-
pollingStarted = false;
|
|
8453
|
-
if (pollTimer) {
|
|
8454
|
-
clearTimeout(pollTimer);
|
|
8455
|
-
pollTimer = null;
|
|
9236
|
+
return status();
|
|
9237
|
+
},
|
|
9238
|
+
dispose() {
|
|
9239
|
+
disposed = true;
|
|
9240
|
+
pollingStarted = false;
|
|
9241
|
+
if (pollTimer) {
|
|
9242
|
+
clearTimeout(pollTimer);
|
|
9243
|
+
pollTimer = null;
|
|
9244
|
+
}
|
|
9245
|
+
}
|
|
9246
|
+
};
|
|
9247
|
+
}
|
|
9248
|
+
//#endregion
|
|
9249
|
+
//#region src/server/playwright-browser.ts
|
|
9250
|
+
const DEFAULT_VIEWPORT = {
|
|
9251
|
+
width: 1280,
|
|
9252
|
+
height: 800
|
|
9253
|
+
};
|
|
9254
|
+
const DEFAULT_MAX_FRAME_RATE = 15;
|
|
9255
|
+
var PlaywrightBrowserHost = class {
|
|
9256
|
+
cachePath;
|
|
9257
|
+
profilePath;
|
|
9258
|
+
browser = null;
|
|
9259
|
+
context = null;
|
|
9260
|
+
pages = /* @__PURE__ */ new Set();
|
|
9261
|
+
operation = Promise.resolve();
|
|
9262
|
+
constructor(cachePath, profilePath) {
|
|
9263
|
+
this.cachePath = cachePath;
|
|
9264
|
+
this.profilePath = profilePath;
|
|
9265
|
+
}
|
|
9266
|
+
get started() {
|
|
9267
|
+
return this.context !== null;
|
|
9268
|
+
}
|
|
9269
|
+
schedule(operation) {
|
|
9270
|
+
const result = this.operation.then(operation);
|
|
9271
|
+
this.operation = result.then(() => void 0, () => void 0);
|
|
9272
|
+
return result;
|
|
9273
|
+
}
|
|
9274
|
+
openPage() {
|
|
9275
|
+
return this.schedule(async () => {
|
|
9276
|
+
let context = this.context;
|
|
9277
|
+
let browser = this.browser;
|
|
9278
|
+
let initialPage = null;
|
|
9279
|
+
if (!context || !browser?.isConnected()) {
|
|
9280
|
+
await fs.mkdir(this.profilePath, {
|
|
9281
|
+
recursive: true,
|
|
9282
|
+
mode: 448
|
|
9283
|
+
});
|
|
9284
|
+
await fs.chmod(this.profilePath, 448);
|
|
9285
|
+
process.env.PLAYWRIGHT_BROWSERS_PATH = this.cachePath;
|
|
9286
|
+
const { chromium } = await import("playwright");
|
|
9287
|
+
context = await chromium.launchPersistentContext(this.profilePath, {
|
|
9288
|
+
channel: "chromium",
|
|
9289
|
+
headless: true,
|
|
9290
|
+
acceptDownloads: false,
|
|
9291
|
+
viewport: DEFAULT_VIEWPORT
|
|
9292
|
+
});
|
|
9293
|
+
browser = context.browser();
|
|
9294
|
+
if (!browser) {
|
|
9295
|
+
await context.close();
|
|
9296
|
+
throw new Error("Playwright did not expose the hosted browser process.");
|
|
9297
|
+
}
|
|
9298
|
+
this.context = context;
|
|
9299
|
+
this.browser = browser;
|
|
9300
|
+
initialPage = context.pages()[0] ?? null;
|
|
9301
|
+
browser.once("disconnected", () => {
|
|
9302
|
+
if (this.browser === browser) {
|
|
9303
|
+
this.browser = null;
|
|
9304
|
+
this.context = null;
|
|
9305
|
+
this.pages.clear();
|
|
9306
|
+
}
|
|
9307
|
+
});
|
|
9308
|
+
}
|
|
9309
|
+
const page = initialPage && !initialPage.isClosed() ? initialPage : await context.newPage();
|
|
9310
|
+
this.pages.add(page);
|
|
9311
|
+
return {
|
|
9312
|
+
browser,
|
|
9313
|
+
context,
|
|
9314
|
+
page
|
|
9315
|
+
};
|
|
9316
|
+
});
|
|
9317
|
+
}
|
|
9318
|
+
closePage(page) {
|
|
9319
|
+
return this.schedule(async () => {
|
|
9320
|
+
this.pages.delete(page);
|
|
9321
|
+
await page.close().catch(() => void 0);
|
|
9322
|
+
if (this.pages.size > 0 || !this.context) return;
|
|
9323
|
+
const context = this.context;
|
|
9324
|
+
this.context = null;
|
|
9325
|
+
this.browser = null;
|
|
9326
|
+
await context.close().catch(() => void 0);
|
|
9327
|
+
});
|
|
9328
|
+
}
|
|
9329
|
+
close() {
|
|
9330
|
+
return this.schedule(async () => {
|
|
9331
|
+
const context = this.context;
|
|
9332
|
+
this.context = null;
|
|
9333
|
+
this.browser = null;
|
|
9334
|
+
this.pages.clear();
|
|
9335
|
+
await context?.close().catch(() => void 0);
|
|
9336
|
+
});
|
|
9337
|
+
}
|
|
9338
|
+
};
|
|
9339
|
+
var LatestBrowserFrameProducer = class {
|
|
9340
|
+
publish;
|
|
9341
|
+
acknowledge;
|
|
9342
|
+
maxFrameRate;
|
|
9343
|
+
active = false;
|
|
9344
|
+
pending = null;
|
|
9345
|
+
timer = null;
|
|
9346
|
+
lastPublishedAt = 0;
|
|
9347
|
+
constructor(publish, acknowledge, maxFrameRate = DEFAULT_MAX_FRAME_RATE) {
|
|
9348
|
+
this.publish = publish;
|
|
9349
|
+
this.acknowledge = acknowledge;
|
|
9350
|
+
this.maxFrameRate = maxFrameRate;
|
|
9351
|
+
}
|
|
9352
|
+
start() {
|
|
9353
|
+
this.active = true;
|
|
9354
|
+
this.lastPublishedAt = 0;
|
|
9355
|
+
}
|
|
9356
|
+
receive(frame) {
|
|
9357
|
+
if (!this.active) {
|
|
9358
|
+
this.acknowledge(frame.sessionId);
|
|
9359
|
+
return;
|
|
9360
|
+
}
|
|
9361
|
+
if (this.pending) this.acknowledge(this.pending.sessionId);
|
|
9362
|
+
this.pending = frame;
|
|
9363
|
+
if (this.timer) return;
|
|
9364
|
+
const minimumInterval = 1e3 / this.maxFrameRate;
|
|
9365
|
+
const delay = Math.max(0, this.lastPublishedAt + minimumInterval - Date.now());
|
|
9366
|
+
if (delay === 0) {
|
|
9367
|
+
this.publishPending();
|
|
9368
|
+
return;
|
|
9369
|
+
}
|
|
9370
|
+
this.timer = setTimeout(() => {
|
|
9371
|
+
this.timer = null;
|
|
9372
|
+
this.publishPending();
|
|
9373
|
+
}, delay);
|
|
9374
|
+
this.timer.unref?.();
|
|
9375
|
+
}
|
|
9376
|
+
stop() {
|
|
9377
|
+
this.active = false;
|
|
9378
|
+
if (this.timer) {
|
|
9379
|
+
clearTimeout(this.timer);
|
|
9380
|
+
this.timer = null;
|
|
9381
|
+
}
|
|
9382
|
+
if (this.pending) {
|
|
9383
|
+
this.acknowledge(this.pending.sessionId);
|
|
9384
|
+
this.pending = null;
|
|
9385
|
+
}
|
|
9386
|
+
}
|
|
9387
|
+
publishPending() {
|
|
9388
|
+
const frame = this.pending;
|
|
9389
|
+
this.pending = null;
|
|
9390
|
+
if (!frame) return;
|
|
9391
|
+
if (!this.active) {
|
|
9392
|
+
this.acknowledge(frame.sessionId);
|
|
9393
|
+
return;
|
|
9394
|
+
}
|
|
9395
|
+
this.lastPublishedAt = Date.now();
|
|
9396
|
+
try {
|
|
9397
|
+
const data = Buffer.from(frame.data, "base64");
|
|
9398
|
+
if (data.byteLength <= 8 * 1024 * 1024) this.publish({
|
|
9399
|
+
mimeType: "image/jpeg",
|
|
9400
|
+
timestamp: frame.metadata.timestamp ? frame.metadata.timestamp * 1e3 : Date.now(),
|
|
9401
|
+
width: frame.metadata.deviceWidth,
|
|
9402
|
+
height: frame.metadata.deviceHeight,
|
|
9403
|
+
data
|
|
9404
|
+
});
|
|
9405
|
+
} finally {
|
|
9406
|
+
this.acknowledge(frame.sessionId);
|
|
9407
|
+
}
|
|
9408
|
+
}
|
|
9409
|
+
};
|
|
9410
|
+
var PlaywrightBrowser = class {
|
|
9411
|
+
host;
|
|
9412
|
+
workspacePath;
|
|
9413
|
+
callbacks;
|
|
9414
|
+
browser = null;
|
|
9415
|
+
context = null;
|
|
9416
|
+
page = null;
|
|
9417
|
+
cdp = null;
|
|
9418
|
+
frameProducer = null;
|
|
9419
|
+
screencasting = false;
|
|
9420
|
+
screencastTail = Promise.resolve();
|
|
9421
|
+
closing = false;
|
|
9422
|
+
dialogHandler = null;
|
|
9423
|
+
consoleMessages = [];
|
|
9424
|
+
requests = [];
|
|
9425
|
+
historyRevision = 0;
|
|
9426
|
+
titleTimer = null;
|
|
9427
|
+
stateValue = {
|
|
9428
|
+
url: "about:blank",
|
|
9429
|
+
title: "",
|
|
9430
|
+
loading: false,
|
|
9431
|
+
canGoBack: false,
|
|
9432
|
+
canGoForward: false,
|
|
9433
|
+
viewport: { ...DEFAULT_VIEWPORT }
|
|
9434
|
+
};
|
|
9435
|
+
constructor(host, workspacePath, callbacks) {
|
|
9436
|
+
this.host = host;
|
|
9437
|
+
this.workspacePath = workspacePath;
|
|
9438
|
+
this.callbacks = callbacks;
|
|
9439
|
+
}
|
|
9440
|
+
static async status(cachePath) {
|
|
9441
|
+
process.env.PLAYWRIGHT_BROWSERS_PATH = cachePath;
|
|
9442
|
+
const [{ chromium }, packageJson] = await Promise.all([import("playwright"), import("playwright/package.json", { with: { type: "json" } })]);
|
|
9443
|
+
const executablePath = chromium.executablePath();
|
|
9444
|
+
const installed = await fs.access(executablePath).then(() => true, () => false);
|
|
9445
|
+
const browserRevision = executablePath.split(/[/\\]/u).find((part) => part.startsWith("chromium-"));
|
|
9446
|
+
let launchReady = false;
|
|
9447
|
+
let launchError = null;
|
|
9448
|
+
if (installed) {
|
|
9449
|
+
const statusProfile = await fs.mkdtemp(path.join(cachePath, ".launch-status-"));
|
|
9450
|
+
const context = await chromium.launchPersistentContext(statusProfile, {
|
|
9451
|
+
channel: "chromium",
|
|
9452
|
+
headless: true,
|
|
9453
|
+
acceptDownloads: false
|
|
9454
|
+
}).catch((error) => {
|
|
9455
|
+
launchError = error instanceof Error ? error.message : String(error);
|
|
9456
|
+
return null;
|
|
9457
|
+
});
|
|
9458
|
+
if (context) {
|
|
9459
|
+
launchReady = true;
|
|
9460
|
+
await context.close();
|
|
9461
|
+
}
|
|
9462
|
+
await fs.rm(statusProfile, {
|
|
9463
|
+
recursive: true,
|
|
9464
|
+
force: true
|
|
9465
|
+
});
|
|
9466
|
+
}
|
|
9467
|
+
return {
|
|
9468
|
+
installed,
|
|
9469
|
+
executablePath,
|
|
9470
|
+
playwrightVersion: String(packageJson.default.version),
|
|
9471
|
+
browserRevision: browserRevision ?? "unknown",
|
|
9472
|
+
channel: "chromium",
|
|
9473
|
+
launchReady,
|
|
9474
|
+
launchError
|
|
9475
|
+
};
|
|
9476
|
+
}
|
|
9477
|
+
get state() {
|
|
9478
|
+
return this.stateValue;
|
|
9479
|
+
}
|
|
9480
|
+
get connected() {
|
|
9481
|
+
return this.browser?.isConnected() ?? false;
|
|
9482
|
+
}
|
|
9483
|
+
async launch() {
|
|
9484
|
+
if (this.browser) return;
|
|
9485
|
+
const { browser, context, page } = await this.host.openPage();
|
|
9486
|
+
this.context = context;
|
|
9487
|
+
this.browser = browser;
|
|
9488
|
+
browser.once("disconnected", () => {
|
|
9489
|
+
if (!this.closing) this.callbacks.crashed("The hosted browser process stopped.");
|
|
9490
|
+
});
|
|
9491
|
+
this.page = page;
|
|
9492
|
+
await page.setViewportSize(this.stateValue.viewport);
|
|
9493
|
+
const cdp = await context.newCDPSession(page);
|
|
9494
|
+
this.cdp = cdp;
|
|
9495
|
+
this.frameProducer = new LatestBrowserFrameProducer((frame) => this.callbacks.frame(frame), (sessionId) => {
|
|
9496
|
+
cdp.send("Page.screencastFrameAck", { sessionId }).catch(() => void 0);
|
|
9497
|
+
});
|
|
9498
|
+
cdp.on("Page.screencastFrame", (frame) => this.frameProducer?.receive(frame));
|
|
9499
|
+
page.on("popup", (candidate) => {
|
|
9500
|
+
(async () => {
|
|
9501
|
+
const supportedUrl = (value) => {
|
|
9502
|
+
if (!URL.canParse(value)) return null;
|
|
9503
|
+
const url = new URL(value);
|
|
9504
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
|
|
9505
|
+
};
|
|
9506
|
+
let popupUrl = supportedUrl(candidate.url());
|
|
9507
|
+
if (!popupUrl) {
|
|
9508
|
+
await candidate.waitForURL((url) => supportedUrl(url.href) !== null, { timeout: 5e3 }).catch(() => void 0);
|
|
9509
|
+
popupUrl = supportedUrl(candidate.url());
|
|
9510
|
+
}
|
|
9511
|
+
if (popupUrl) this.callbacks.popup(popupUrl);
|
|
9512
|
+
else this.callbacks.navigationError("The popup did not supply a supported HTTP or HTTPS URL.");
|
|
9513
|
+
await candidate.close().catch(() => void 0);
|
|
9514
|
+
})();
|
|
9515
|
+
});
|
|
9516
|
+
page.on("download", (download) => {
|
|
9517
|
+
this.callbacks.navigationError("Downloads are not supported in Browser.");
|
|
9518
|
+
download.cancel();
|
|
9519
|
+
});
|
|
9520
|
+
page.on("filechooser", (chooser) => {
|
|
9521
|
+
this.callbacks.navigationError("File pickers are not supported in Browser.");
|
|
9522
|
+
chooser.setFiles([]).catch(() => void 0);
|
|
9523
|
+
});
|
|
9524
|
+
page.on("close", () => {
|
|
9525
|
+
if (!this.closing) this.callbacks.crashed("The hosted browser page closed.");
|
|
9526
|
+
});
|
|
9527
|
+
page.on("console", (message) => {
|
|
9528
|
+
this.consoleMessages.push(`${message.type()}: ${message.text()}`);
|
|
9529
|
+
if (this.consoleMessages.length > 1e3) this.consoleMessages.shift();
|
|
9530
|
+
});
|
|
9531
|
+
page.on("pageerror", (error) => {
|
|
9532
|
+
this.consoleMessages.push(`error: ${error.message}`);
|
|
9533
|
+
if (this.consoleMessages.length > 1e3) this.consoleMessages.shift();
|
|
9534
|
+
});
|
|
9535
|
+
page.on("request", (request) => {
|
|
9536
|
+
this.requests.push(`${request.method()} ${request.url()}`);
|
|
9537
|
+
if (this.requests.length > 2e3) this.requests.shift();
|
|
9538
|
+
if (request.isNavigationRequest() && request.frame() === page.mainFrame()) this.updateState({ loading: true });
|
|
9539
|
+
});
|
|
9540
|
+
page.on("requestfailed", (request) => {
|
|
9541
|
+
if (request.isNavigationRequest() && request.frame() === page.mainFrame()) {
|
|
9542
|
+
this.updateState({ loading: false });
|
|
9543
|
+
this.callbacks.navigationError(request.failure()?.errorText ?? "Navigation failed.");
|
|
9544
|
+
}
|
|
9545
|
+
});
|
|
9546
|
+
page.on("framenavigated", (frame) => {
|
|
9547
|
+
if (frame === page.mainFrame()) this.refreshPageState();
|
|
9548
|
+
});
|
|
9549
|
+
page.on("domcontentloaded", () => void this.refreshPageState());
|
|
9550
|
+
page.on("load", () => {
|
|
9551
|
+
this.updateState({ loading: false });
|
|
9552
|
+
this.refreshPageState();
|
|
9553
|
+
});
|
|
9554
|
+
this.dialogHandler = (dialog) => void dialog.dismiss();
|
|
9555
|
+
page.on("dialog", this.dialogHandler);
|
|
9556
|
+
page.once("crash", () => this.callbacks.crashed("The hosted browser page crashed."));
|
|
9557
|
+
this.cdp.on("Page.navigatedWithinDocument", () => {
|
|
9558
|
+
this.refreshPageState();
|
|
9559
|
+
});
|
|
9560
|
+
await this.cdp.send("Page.enable");
|
|
9561
|
+
this.titleTimer = setInterval(() => {
|
|
9562
|
+
page.title().then((title) => {
|
|
9563
|
+
if (title !== this.stateValue.title) this.updateState({ title });
|
|
9564
|
+
}).catch(() => void 0);
|
|
9565
|
+
}, 500);
|
|
9566
|
+
this.titleTimer.unref();
|
|
9567
|
+
await this.refreshPageState();
|
|
9568
|
+
}
|
|
9569
|
+
updateState(patch) {
|
|
9570
|
+
this.stateValue = {
|
|
9571
|
+
...this.stateValue,
|
|
9572
|
+
...patch,
|
|
9573
|
+
viewport: patch.viewport ?? this.stateValue.viewport
|
|
9574
|
+
};
|
|
9575
|
+
this.callbacks.state(this.stateValue);
|
|
9576
|
+
}
|
|
9577
|
+
async refreshPageState() {
|
|
9578
|
+
const page = this.page;
|
|
9579
|
+
const cdp = this.cdp;
|
|
9580
|
+
if (!page || !cdp || page.isClosed()) return;
|
|
9581
|
+
const revision = ++this.historyRevision;
|
|
9582
|
+
const [title, history] = await Promise.all([page.title().catch(() => ""), cdp.send("Page.getNavigationHistory").catch(() => ({
|
|
9583
|
+
currentIndex: 0,
|
|
9584
|
+
entries: [{
|
|
9585
|
+
id: 0,
|
|
9586
|
+
url: page.url(),
|
|
9587
|
+
userTypedURL: "",
|
|
9588
|
+
title: "",
|
|
9589
|
+
transitionType: "typed"
|
|
9590
|
+
}]
|
|
9591
|
+
}))]);
|
|
9592
|
+
if (revision !== this.historyRevision || page.isClosed()) return;
|
|
9593
|
+
this.updateState({
|
|
9594
|
+
url: page.url(),
|
|
9595
|
+
title,
|
|
9596
|
+
canGoBack: history.currentIndex > 0,
|
|
9597
|
+
canGoForward: history.currentIndex < history.entries.length - 1
|
|
9598
|
+
});
|
|
9599
|
+
}
|
|
9600
|
+
async setScreencasting(enabled) {
|
|
9601
|
+
const operation = this.screencastTail.then(async () => {
|
|
9602
|
+
const page = this.page;
|
|
9603
|
+
const cdp = this.cdp;
|
|
9604
|
+
const frameProducer = this.frameProducer;
|
|
9605
|
+
if (!page || page.isClosed() || !cdp || !frameProducer || enabled === this.screencasting) return;
|
|
9606
|
+
this.screencasting = enabled;
|
|
9607
|
+
if (!enabled) {
|
|
9608
|
+
frameProducer.stop();
|
|
9609
|
+
await cdp.send("Page.stopScreencast").catch(() => void 0);
|
|
9610
|
+
return;
|
|
9611
|
+
}
|
|
9612
|
+
frameProducer.start();
|
|
9613
|
+
await cdp.send("Page.startScreencast", {
|
|
9614
|
+
format: "jpeg",
|
|
9615
|
+
quality: 75,
|
|
9616
|
+
maxWidth: this.stateValue.viewport.width,
|
|
9617
|
+
maxHeight: this.stateValue.viewport.height,
|
|
9618
|
+
everyNthFrame: 1
|
|
9619
|
+
}).catch((error) => {
|
|
9620
|
+
this.screencasting = false;
|
|
9621
|
+
frameProducer.stop();
|
|
9622
|
+
throw error;
|
|
9623
|
+
});
|
|
9624
|
+
});
|
|
9625
|
+
this.screencastTail = operation.catch(() => void 0);
|
|
9626
|
+
return operation;
|
|
9627
|
+
}
|
|
9628
|
+
async command(message) {
|
|
9629
|
+
const page = this.page;
|
|
9630
|
+
if (!page || page.isClosed()) throw new Error("The hosted browser page is unavailable.");
|
|
9631
|
+
if (message.type === "navigate") {
|
|
9632
|
+
this.updateState({ loading: true });
|
|
9633
|
+
await page.goto(message.url, { waitUntil: "commit" }).catch((error) => {
|
|
9634
|
+
this.updateState({ loading: false });
|
|
9635
|
+
throw error;
|
|
9636
|
+
});
|
|
9637
|
+
await this.refreshPageState();
|
|
9638
|
+
return;
|
|
9639
|
+
}
|
|
9640
|
+
if (message.type === "back") {
|
|
9641
|
+
await page.goBack({ waitUntil: "commit" });
|
|
9642
|
+
await this.refreshPageState();
|
|
9643
|
+
return;
|
|
9644
|
+
}
|
|
9645
|
+
if (message.type === "forward") {
|
|
9646
|
+
await page.goForward({ waitUntil: "commit" });
|
|
9647
|
+
await this.refreshPageState();
|
|
9648
|
+
return;
|
|
9649
|
+
}
|
|
9650
|
+
if (message.type === "reload") {
|
|
9651
|
+
this.updateState({ loading: true });
|
|
9652
|
+
await page.reload({ waitUntil: "commit" }).catch((error) => {
|
|
9653
|
+
this.updateState({ loading: false });
|
|
9654
|
+
throw error;
|
|
9655
|
+
});
|
|
9656
|
+
await this.refreshPageState();
|
|
9657
|
+
return;
|
|
9658
|
+
}
|
|
9659
|
+
if (message.type === "stop") {
|
|
9660
|
+
await this.cdp?.send("Page.stopLoading");
|
|
9661
|
+
this.updateState({ loading: false });
|
|
9662
|
+
return;
|
|
9663
|
+
}
|
|
9664
|
+
if (message.type === "resize") {
|
|
9665
|
+
const wasScreencasting = this.screencasting;
|
|
9666
|
+
if (wasScreencasting) await this.setScreencasting(false);
|
|
9667
|
+
await page.setViewportSize({
|
|
9668
|
+
width: message.width,
|
|
9669
|
+
height: message.height
|
|
9670
|
+
});
|
|
9671
|
+
this.updateState({ viewport: {
|
|
9672
|
+
width: message.width,
|
|
9673
|
+
height: message.height
|
|
9674
|
+
} });
|
|
9675
|
+
if (wasScreencasting) await this.setScreencasting(true);
|
|
9676
|
+
return;
|
|
9677
|
+
}
|
|
9678
|
+
if (message.type === "pointer") {
|
|
9679
|
+
if (message.phase === "move") await page.mouse.move(message.x, message.y);
|
|
9680
|
+
else if (message.phase === "down") {
|
|
9681
|
+
await page.mouse.move(message.x, message.y);
|
|
9682
|
+
await page.mouse.down({ button: message.button ?? "left" });
|
|
9683
|
+
} else {
|
|
9684
|
+
await page.mouse.move(message.x, message.y);
|
|
9685
|
+
await page.mouse.up({ button: message.button ?? "left" });
|
|
9686
|
+
}
|
|
9687
|
+
return;
|
|
9688
|
+
}
|
|
9689
|
+
if (message.type === "wheel") {
|
|
9690
|
+
await page.mouse.wheel(message.deltaX, message.deltaY);
|
|
9691
|
+
return;
|
|
9692
|
+
}
|
|
9693
|
+
if (message.type === "key") {
|
|
9694
|
+
if (message.phase === "down") await page.keyboard.down(message.key);
|
|
9695
|
+
else await page.keyboard.up(message.key);
|
|
9696
|
+
return;
|
|
9697
|
+
}
|
|
9698
|
+
if (message.type === "insertText") await page.keyboard.insertText(message.text);
|
|
9699
|
+
}
|
|
9700
|
+
async agentCommand(input) {
|
|
9701
|
+
const page = this.page;
|
|
9702
|
+
if (!page || page.isClosed()) throw new Error("The hosted browser page is unavailable.");
|
|
9703
|
+
const target = (value) => page.locator(`aria-ref=${value}`);
|
|
9704
|
+
if (input.command === "snapshot") return Reflect.apply(page.ariaSnapshot, page, [{ mode: "ai" }]);
|
|
9705
|
+
if (input.command === "click") {
|
|
9706
|
+
await target(input.args[0]).click();
|
|
9707
|
+
return `Clicked ${input.args[0]}`;
|
|
9708
|
+
}
|
|
9709
|
+
if (input.command === "fill") {
|
|
9710
|
+
await target(input.args[0]).fill(input.args[1]);
|
|
9711
|
+
return `Filled ${input.args[0]}`;
|
|
9712
|
+
}
|
|
9713
|
+
if (input.command === "press") {
|
|
9714
|
+
await page.keyboard.press(input.args[0]);
|
|
9715
|
+
return `Pressed ${input.args[0]}`;
|
|
9716
|
+
}
|
|
9717
|
+
if (input.command === "console") {
|
|
9718
|
+
const minimum = input.args[0] ?? "info";
|
|
9719
|
+
const levels = [
|
|
9720
|
+
"debug",
|
|
9721
|
+
"info",
|
|
9722
|
+
"warning",
|
|
9723
|
+
"error"
|
|
9724
|
+
];
|
|
9725
|
+
const minimumIndex = Math.max(0, levels.indexOf(minimum));
|
|
9726
|
+
return this.consoleMessages.filter((line) => {
|
|
9727
|
+
const level = line.slice(0, line.indexOf(":"));
|
|
9728
|
+
const index = levels.indexOf(level);
|
|
9729
|
+
return index < 0 || index >= minimumIndex;
|
|
9730
|
+
}).join("\n") || "No console messages.";
|
|
9731
|
+
}
|
|
9732
|
+
if (input.command === "requests") return this.requests.join("\n") || "No network requests.";
|
|
9733
|
+
if (input.command === "screenshot") {
|
|
9734
|
+
const screenshotPath = path.join(this.workspacePath, `screenshot-${Date.now()}.png`);
|
|
9735
|
+
await page.screenshot({ path: screenshotPath });
|
|
9736
|
+
return `Screenshot saved to ${screenshotPath}`;
|
|
9737
|
+
}
|
|
9738
|
+
if (input.command === "goto") {
|
|
9739
|
+
await page.goto(input.args[0]);
|
|
9740
|
+
await this.refreshPageState();
|
|
9741
|
+
return `Navigated to ${page.url()}`;
|
|
9742
|
+
}
|
|
9743
|
+
if (input.command === "go-back") {
|
|
9744
|
+
await page.goBack();
|
|
9745
|
+
await this.refreshPageState();
|
|
9746
|
+
return `Navigated to ${page.url()}`;
|
|
9747
|
+
}
|
|
9748
|
+
if (input.command === "go-forward") {
|
|
9749
|
+
await page.goForward();
|
|
9750
|
+
await this.refreshPageState();
|
|
9751
|
+
return `Navigated to ${page.url()}`;
|
|
9752
|
+
}
|
|
9753
|
+
await page.reload();
|
|
9754
|
+
await this.refreshPageState();
|
|
9755
|
+
return `Reloaded ${page.url()}`;
|
|
9756
|
+
}
|
|
9757
|
+
async requestClose(force) {
|
|
9758
|
+
const page = this.page;
|
|
9759
|
+
if (!page || page.isClosed()) return true;
|
|
9760
|
+
const previousClosing = this.closing;
|
|
9761
|
+
this.closing = true;
|
|
9762
|
+
if (this.dialogHandler) page.off("dialog", this.dialogHandler);
|
|
9763
|
+
let timer = null;
|
|
9764
|
+
let closeHandler = null;
|
|
9765
|
+
let dialogHandler = null;
|
|
9766
|
+
try {
|
|
9767
|
+
return await new Promise((resolve, reject) => {
|
|
9768
|
+
closeHandler = () => resolve(true);
|
|
9769
|
+
dialogHandler = (dialog) => {
|
|
9770
|
+
if (dialog.type() !== "beforeunload") {
|
|
9771
|
+
dialog.dismiss().catch(reject);
|
|
9772
|
+
return;
|
|
9773
|
+
}
|
|
9774
|
+
if (force) dialog.accept().catch(reject);
|
|
9775
|
+
else dialog.dismiss().then(() => resolve(false), reject);
|
|
9776
|
+
};
|
|
9777
|
+
page.once("close", closeHandler);
|
|
9778
|
+
page.on("dialog", dialogHandler);
|
|
9779
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error("The page did not finish closing.")), 5e3);
|
|
9780
|
+
timer.unref();
|
|
9781
|
+
page.close({ runBeforeUnload: true }).catch(reject);
|
|
9782
|
+
});
|
|
9783
|
+
} finally {
|
|
9784
|
+
if (timer) clearTimeout(timer);
|
|
9785
|
+
if (closeHandler) page.off("close", closeHandler);
|
|
9786
|
+
if (dialogHandler) page.off("dialog", dialogHandler);
|
|
9787
|
+
if (!page.isClosed() && this.dialogHandler) page.on("dialog", this.dialogHandler);
|
|
9788
|
+
this.closing = previousClosing;
|
|
9789
|
+
}
|
|
9790
|
+
}
|
|
9791
|
+
async close() {
|
|
9792
|
+
if (this.closing) return;
|
|
9793
|
+
this.closing = true;
|
|
9794
|
+
this.frameProducer?.stop();
|
|
9795
|
+
await this.setScreencasting(false).catch(() => void 0);
|
|
9796
|
+
if (this.titleTimer) {
|
|
9797
|
+
clearInterval(this.titleTimer);
|
|
9798
|
+
this.titleTimer = null;
|
|
9799
|
+
}
|
|
9800
|
+
await this.screencastTail;
|
|
9801
|
+
const page = this.page;
|
|
9802
|
+
if (page) await this.host.closePage(page);
|
|
9803
|
+
this.page = null;
|
|
9804
|
+
this.context = null;
|
|
9805
|
+
this.cdp = null;
|
|
9806
|
+
this.frameProducer = null;
|
|
9807
|
+
this.browser = null;
|
|
9808
|
+
}
|
|
9809
|
+
};
|
|
9810
|
+
//#endregion
|
|
9811
|
+
//#region src/server/browser-sessions.ts
|
|
9812
|
+
const MAX_BROWSER_ATTACHMENTS = 8;
|
|
9813
|
+
const MAX_BROWSER_TICKETS = 256;
|
|
9814
|
+
const LOCAL_BROWSER_OWNER_CONTROLLER = "local-owner";
|
|
9815
|
+
const attachmentController = (clientId) => `attachment:${clientId}`;
|
|
9816
|
+
const MAX_BROWSER_SCHEDULED_OPERATIONS = 64;
|
|
9817
|
+
const MAX_BROWSER_REGULAR_OPERATIONS = 46;
|
|
9818
|
+
const playwrightPackageSchema = z.object({ bin: z.object({ playwright: z.string() }).optional() });
|
|
9819
|
+
const defaultBrowserFactory = (host, workspacePath, _title, _panelId, _worktreeId, callbacks) => new PlaywrightBrowser(host, workspacePath, callbacks);
|
|
9820
|
+
const DEFAULT_STATE = {
|
|
9821
|
+
url: "about:blank",
|
|
9822
|
+
title: "",
|
|
9823
|
+
loading: false,
|
|
9824
|
+
canGoBack: false,
|
|
9825
|
+
canGoForward: false,
|
|
9826
|
+
viewport: {
|
|
9827
|
+
width: 1280,
|
|
9828
|
+
height: 800
|
|
9829
|
+
}
|
|
9830
|
+
};
|
|
9831
|
+
var BrowserSessionManager = class {
|
|
9832
|
+
service;
|
|
9833
|
+
config;
|
|
9834
|
+
browserFactory;
|
|
9835
|
+
agentCliRunner;
|
|
9836
|
+
connectLocalAutomation;
|
|
9837
|
+
cachePath;
|
|
9838
|
+
browserHost;
|
|
9839
|
+
sessions = /* @__PURE__ */ new Map();
|
|
9840
|
+
sessionCreations = /* @__PURE__ */ new Map();
|
|
9841
|
+
tickets = /* @__PURE__ */ new Map();
|
|
9842
|
+
ownerTickets = /* @__PURE__ */ new Map();
|
|
9843
|
+
unsubscribe;
|
|
9844
|
+
installing = null;
|
|
9845
|
+
constructor(service, config, browserFactory = defaultBrowserFactory, agentCliRunner = null, connectLocalAutomation = async (endpoint) => {
|
|
9846
|
+
const { chromium } = await import("playwright");
|
|
9847
|
+
return chromium.connectOverCDP(endpoint, { timeout: 1e4 });
|
|
9848
|
+
}) {
|
|
9849
|
+
this.service = service;
|
|
9850
|
+
this.config = config;
|
|
9851
|
+
this.browserFactory = browserFactory;
|
|
9852
|
+
this.agentCliRunner = agentCliRunner;
|
|
9853
|
+
this.connectLocalAutomation = connectLocalAutomation;
|
|
9854
|
+
this.cachePath = path.join(config.cacheDir, "playwright");
|
|
9855
|
+
this.browserHost = new PlaywrightBrowserHost(this.cachePath, path.join(config.dataDir, "browser-profile"));
|
|
9856
|
+
this.unsubscribe = service.events.subscribe((event) => {
|
|
9857
|
+
if (event.type === "panel.removed") this.closePanel(String(event.data.panelId), "Panel closed");
|
|
9858
|
+
else if (event.type === "worktree.removed" && event.data.worktreeId) for (const session of this.sessions.values()) this.service.authorizeBrowserPanel(session.panelId).catch(() => this.closePanel(session.panelId, "Worktree removed"));
|
|
9859
|
+
});
|
|
9860
|
+
}
|
|
9861
|
+
async issueTicket(panelId, clientId, visible = true) {
|
|
9862
|
+
await this.service.authorizeBrowserPanel(panelId);
|
|
9863
|
+
for (const [value, ticket] of this.tickets) if (ticket.expiresAt < Date.now()) this.tickets.delete(value);
|
|
9864
|
+
if (this.tickets.size >= MAX_BROWSER_TICKETS) throw new Error("Too many Browser attachment requests are pending.");
|
|
9865
|
+
const ticket = crypto.randomBytes(32).toString("base64url");
|
|
9866
|
+
this.tickets.set(ticket, {
|
|
9867
|
+
panelId,
|
|
9868
|
+
clientId,
|
|
9869
|
+
visible,
|
|
9870
|
+
expiresAt: Date.now() + 3e4
|
|
9871
|
+
});
|
|
9872
|
+
return ticket;
|
|
9873
|
+
}
|
|
9874
|
+
async issueOwnerTicket(panelId, clientId) {
|
|
9875
|
+
await this.service.authorizeBrowserPanel(panelId);
|
|
9876
|
+
for (const [value, ticket] of this.ownerTickets) if (ticket.expiresAt < Date.now()) this.ownerTickets.delete(value);
|
|
9877
|
+
if (this.ownerTickets.size >= MAX_BROWSER_TICKETS) throw new Error("Too many Browser owner requests are pending.");
|
|
9878
|
+
const ticket = crypto.randomBytes(32).toString("base64url");
|
|
9879
|
+
const currentOwner = this.sessions.get(panelId)?.localOwner;
|
|
9880
|
+
const challenge = currentOwner?.clientId === clientId ? currentOwner.challenge : crypto.randomBytes(32).toString("base64url");
|
|
9881
|
+
this.ownerTickets.set(ticket, {
|
|
9882
|
+
panelId,
|
|
9883
|
+
clientId,
|
|
9884
|
+
challenge,
|
|
9885
|
+
expiresAt: Date.now() + 3e4
|
|
9886
|
+
});
|
|
9887
|
+
return {
|
|
9888
|
+
ticket,
|
|
9889
|
+
challenge
|
|
9890
|
+
};
|
|
9891
|
+
}
|
|
9892
|
+
stateFor(session, attachment) {
|
|
9893
|
+
return {
|
|
9894
|
+
...session.state,
|
|
9895
|
+
controlled: session.controllerId === attachmentController(attachment.clientId),
|
|
9896
|
+
hasController: session.localOwner !== null || session.controllerId !== null,
|
|
9897
|
+
controller: session.controllerId === attachmentController(attachment.clientId) ? "you" : session.controllerId === "agent" ? "agent" : session.localOwner || session.controllerId ? "other" : "none"
|
|
9898
|
+
};
|
|
9899
|
+
}
|
|
9900
|
+
broadcastState(session, type = "state") {
|
|
9901
|
+
for (const attachment of session.attachments.values()) attachment.transport.sendMessage({
|
|
9902
|
+
type,
|
|
9903
|
+
state: this.stateFor(session, attachment)
|
|
9904
|
+
});
|
|
9905
|
+
}
|
|
9906
|
+
enqueueOperation(session, operation, allowWhenClosing = false) {
|
|
9907
|
+
const scheduler = session.scheduler;
|
|
9908
|
+
if (!scheduler.accepting && !allowWhenClosing) return false;
|
|
9909
|
+
if (operation.coalesceKey) {
|
|
9910
|
+
const existing = scheduler.coalesced.get(operation.coalesceKey);
|
|
9911
|
+
if (existing) {
|
|
9912
|
+
if (existing.message?.type === "wheel" && operation.message?.type === "wheel") existing.message = {
|
|
9913
|
+
type: "wheel",
|
|
9914
|
+
deltaX: Math.max(-1e4, Math.min(1e4, existing.message.deltaX + operation.message.deltaX)),
|
|
9915
|
+
deltaY: Math.max(-1e4, Math.min(1e4, existing.message.deltaY + operation.message.deltaY))
|
|
9916
|
+
};
|
|
9917
|
+
else existing.message = operation.message;
|
|
9918
|
+
existing.execute = operation.execute;
|
|
9919
|
+
existing.completions.push(...operation.completions);
|
|
9920
|
+
existing.required ||= operation.required;
|
|
9921
|
+
return true;
|
|
9922
|
+
}
|
|
9923
|
+
}
|
|
9924
|
+
if (scheduler.queue.length >= MAX_BROWSER_SCHEDULED_OPERATIONS || !operation.required && scheduler.queue.length >= MAX_BROWSER_REGULAR_OPERATIONS) return false;
|
|
9925
|
+
scheduler.queue.push(operation);
|
|
9926
|
+
if (operation.coalesceKey) scheduler.coalesced.set(operation.coalesceKey, operation);
|
|
9927
|
+
this.runScheduler(session);
|
|
9928
|
+
return true;
|
|
9929
|
+
}
|
|
9930
|
+
scheduleOperation(session, execute, options = {}) {
|
|
9931
|
+
return new Promise((resolve, reject) => {
|
|
9932
|
+
if (!this.enqueueOperation(session, {
|
|
9933
|
+
coalesceKey: options.coalesceKey ?? null,
|
|
9934
|
+
message: null,
|
|
9935
|
+
execute: () => execute(),
|
|
9936
|
+
completions: [{
|
|
9937
|
+
resolve,
|
|
9938
|
+
reject
|
|
9939
|
+
}],
|
|
9940
|
+
required: options.required ?? false
|
|
9941
|
+
}, options.allowWhenClosing)) reject(/* @__PURE__ */ new Error(session.scheduler.accepting ? "The Browser command queue is full." : "The Browser session is closing."));
|
|
9942
|
+
});
|
|
9943
|
+
}
|
|
9944
|
+
queueClientOperation(session, attachment, operation) {
|
|
9945
|
+
if (!this.enqueueOperation(session, {
|
|
9946
|
+
...operation,
|
|
9947
|
+
completions: []
|
|
9948
|
+
})) attachment.transport.sendMessage({
|
|
9949
|
+
type: "navigationError",
|
|
9950
|
+
message: session.scheduler.accepting ? "The Browser command queue is full. Wait and try again." : "The Browser session is closing."
|
|
9951
|
+
});
|
|
9952
|
+
}
|
|
9953
|
+
runScheduler(session) {
|
|
9954
|
+
const scheduler = session.scheduler;
|
|
9955
|
+
if (scheduler.running) return;
|
|
9956
|
+
scheduler.running = true;
|
|
9957
|
+
(async () => {
|
|
9958
|
+
while (scheduler.queue.length > 0) {
|
|
9959
|
+
const operation = scheduler.queue.shift();
|
|
9960
|
+
if (operation.coalesceKey) scheduler.coalesced.delete(operation.coalesceKey);
|
|
9961
|
+
try {
|
|
9962
|
+
await operation.execute(operation.message);
|
|
9963
|
+
for (const completion of operation.completions) completion.resolve();
|
|
9964
|
+
} catch (cause) {
|
|
9965
|
+
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
9966
|
+
for (const completion of operation.completions) completion.reject(error);
|
|
9967
|
+
}
|
|
9968
|
+
}
|
|
9969
|
+
scheduler.running = false;
|
|
9970
|
+
})();
|
|
9971
|
+
}
|
|
9972
|
+
stopScheduler(session, reason) {
|
|
9973
|
+
session.scheduler.accepting = false;
|
|
9974
|
+
const error = new Error(reason);
|
|
9975
|
+
for (const operation of session.scheduler.queue.splice(0)) for (const completion of operation.completions) completion.reject(error);
|
|
9976
|
+
session.scheduler.coalesced.clear();
|
|
9977
|
+
}
|
|
9978
|
+
broadcastNavigationError(session, message) {
|
|
9979
|
+
for (const attachment of session.attachments.values()) attachment.transport.sendMessage({
|
|
9980
|
+
type: "navigationError",
|
|
9981
|
+
message
|
|
9982
|
+
});
|
|
9983
|
+
}
|
|
9984
|
+
async openPopup(session, url) {
|
|
9985
|
+
await this.service.openBrowserPanelFromPanel(session.panelId, url).catch((cause) => this.broadcastNavigationError(session, `Could not open the popup: ${cause instanceof Error ? cause.message : String(cause)}`));
|
|
9986
|
+
}
|
|
9987
|
+
queuePanelState(session, value) {
|
|
9988
|
+
if (session.closing || !session.persistence.ready) return;
|
|
9989
|
+
const parsed = value.url === "about:blank" ? {
|
|
9990
|
+
success: true,
|
|
9991
|
+
data: "about:blank"
|
|
9992
|
+
} : browserUrlSchema.safeParse(value.url);
|
|
9993
|
+
if (!parsed.success) return;
|
|
9994
|
+
const url = parsed.data === "about:blank" ? parsed.data : new URL(parsed.data).href;
|
|
9995
|
+
const title = value.title.trim().slice(0, 256) || (url === "about:blank" ? "Browser" : new URL(url).host || "Browser");
|
|
9996
|
+
const persistence = session.persistence;
|
|
9997
|
+
if (persistence.pending?.url === url && persistence.pending.title === title || persistence.persistedUrl === url && persistence.persistedTitle === title) return;
|
|
9998
|
+
persistence.pending = {
|
|
9999
|
+
url,
|
|
10000
|
+
title
|
|
10001
|
+
};
|
|
10002
|
+
if (persistence.write) return;
|
|
10003
|
+
const write = (async () => {
|
|
10004
|
+
while (persistence.pending !== null) {
|
|
10005
|
+
const pending = persistence.pending;
|
|
10006
|
+
persistence.pending = null;
|
|
10007
|
+
try {
|
|
10008
|
+
const panel = await this.service.updateBrowserPanelState(session.panelId, pending);
|
|
10009
|
+
persistence.persistedUrl = panel.url;
|
|
10010
|
+
persistence.persistedTitle = panel.title;
|
|
10011
|
+
} catch (cause) {
|
|
10012
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
10013
|
+
for (const attachment of session.attachments.values()) attachment.transport.sendMessage({
|
|
10014
|
+
type: "navigationError",
|
|
10015
|
+
message: `Could not save the Browser address and title: ${message}`
|
|
10016
|
+
});
|
|
10017
|
+
return;
|
|
10018
|
+
}
|
|
10019
|
+
}
|
|
10020
|
+
})();
|
|
10021
|
+
persistence.write = write;
|
|
10022
|
+
write.finally(() => {
|
|
10023
|
+
if (persistence.write !== write) return;
|
|
10024
|
+
persistence.write = null;
|
|
10025
|
+
const pending = persistence.pending;
|
|
10026
|
+
persistence.pending = null;
|
|
10027
|
+
if (pending !== null && !session.closing) this.queuePanelState(session, pending);
|
|
10028
|
+
});
|
|
10029
|
+
}
|
|
10030
|
+
async waitForPanelState(session) {
|
|
10031
|
+
while (session.persistence.write) await session.persistence.write;
|
|
10032
|
+
}
|
|
10033
|
+
async createSession(panelId) {
|
|
10034
|
+
const authorized = await this.service.authorizeBrowserPanel(panelId);
|
|
10035
|
+
const restoredUrl = authorized.panel.url === "about:blank" ? null : browserUrlSchema.parse(authorized.panel.url);
|
|
10036
|
+
const agentDirectory = path.join(this.config.runtimeDir, "browsers", panelId);
|
|
10037
|
+
await fs.rm(agentDirectory, {
|
|
10038
|
+
recursive: true,
|
|
10039
|
+
force: true
|
|
10040
|
+
});
|
|
10041
|
+
await fs.mkdir(agentDirectory, {
|
|
10042
|
+
recursive: true,
|
|
10043
|
+
mode: 448
|
|
10044
|
+
});
|
|
10045
|
+
await fs.chmod(agentDirectory, 448);
|
|
10046
|
+
const session = {
|
|
10047
|
+
panelId,
|
|
10048
|
+
agentDirectory,
|
|
10049
|
+
title: `Treeport ${authorized.panel.title}`,
|
|
10050
|
+
browser: null,
|
|
10051
|
+
launch: null,
|
|
10052
|
+
localOwner: null,
|
|
10053
|
+
localAutomation: null,
|
|
10054
|
+
localAutomationLaunch: null,
|
|
10055
|
+
generation: 0,
|
|
10056
|
+
attachments: /* @__PURE__ */ new Map(),
|
|
10057
|
+
controllerId: null,
|
|
10058
|
+
state: {
|
|
10059
|
+
...DEFAULT_STATE,
|
|
10060
|
+
url: restoredUrl ?? DEFAULT_STATE.url,
|
|
10061
|
+
title: authorized.panel.title === "Browser" ? "" : authorized.panel.title,
|
|
10062
|
+
viewport: { ...DEFAULT_STATE.viewport }
|
|
10063
|
+
},
|
|
10064
|
+
sequence: 0,
|
|
10065
|
+
latestFrame: null,
|
|
10066
|
+
scheduler: {
|
|
10067
|
+
queue: [],
|
|
10068
|
+
coalesced: /* @__PURE__ */ new Map(),
|
|
10069
|
+
running: false,
|
|
10070
|
+
accepting: true
|
|
10071
|
+
},
|
|
10072
|
+
persistence: {
|
|
10073
|
+
persistedUrl: authorized.panel.url,
|
|
10074
|
+
persistedTitle: authorized.panel.title,
|
|
10075
|
+
pending: null,
|
|
10076
|
+
write: null,
|
|
10077
|
+
ready: true
|
|
10078
|
+
},
|
|
10079
|
+
agentAttached: false,
|
|
10080
|
+
agentSessionName: null,
|
|
10081
|
+
agentProcess: null,
|
|
10082
|
+
crashMessage: null,
|
|
10083
|
+
closing: false,
|
|
10084
|
+
closeOperation: null
|
|
10085
|
+
};
|
|
10086
|
+
this.sessions.set(panelId, session);
|
|
10087
|
+
return session;
|
|
10088
|
+
}
|
|
10089
|
+
async ensurePlaywrightRuntime(session) {
|
|
10090
|
+
if (session.localOwner) throw new Error("This Browser is open in a local Treeport desktop app.");
|
|
10091
|
+
if (session.launch) return session.launch;
|
|
10092
|
+
if ([...this.sessions.values()].filter((candidate) => candidate.localOwner || candidate.launch).length >= 6) throw new Error("Treeport supports at most six active Browser sessions. Close one and try again.");
|
|
10093
|
+
const runtimeGeneration = ++session.generation;
|
|
10094
|
+
const restoredUrl = session.state.url;
|
|
10095
|
+
const restoredTitle = session.state.title;
|
|
10096
|
+
const launch = (async () => {
|
|
10097
|
+
await fs.rm(session.agentDirectory, {
|
|
10098
|
+
recursive: true,
|
|
10099
|
+
force: true
|
|
10100
|
+
});
|
|
10101
|
+
await fs.mkdir(session.agentDirectory, {
|
|
10102
|
+
recursive: true,
|
|
10103
|
+
mode: 448
|
|
10104
|
+
});
|
|
10105
|
+
await fs.chmod(session.agentDirectory, 448);
|
|
10106
|
+
let runtimeReady = false;
|
|
10107
|
+
const browser = this.browserFactory(this.browserHost, session.agentDirectory, session.title, session.panelId, (await this.service.authorizeBrowserPanel(session.panelId)).panel.worktreeId, {
|
|
10108
|
+
state: (state) => {
|
|
10109
|
+
if (!runtimeReady || session.browser !== browser || session.generation !== runtimeGeneration || session.localOwner) return;
|
|
10110
|
+
session.state = state;
|
|
10111
|
+
this.queuePanelState(session, state);
|
|
10112
|
+
this.broadcastState(session);
|
|
10113
|
+
},
|
|
10114
|
+
frame: (frame) => {
|
|
10115
|
+
if (runtimeReady && session.browser === browser && session.generation === runtimeGeneration && !session.localOwner) this.publishFrame(session, frame);
|
|
10116
|
+
},
|
|
10117
|
+
popup: (url) => {
|
|
10118
|
+
if (!runtimeReady || session.browser !== browser || session.generation !== runtimeGeneration || session.localOwner) return;
|
|
10119
|
+
this.openPopup(session, url);
|
|
10120
|
+
},
|
|
10121
|
+
navigationError: (message) => this.broadcastNavigationError(session, message),
|
|
10122
|
+
crashed: (message) => {
|
|
10123
|
+
if (!runtimeReady || session.browser !== browser || session.generation !== runtimeGeneration || session.localOwner) return;
|
|
10124
|
+
session.crashMessage = message;
|
|
10125
|
+
for (const attachment of session.attachments.values()) attachment.transport.sendMessage({
|
|
10126
|
+
type: "browserCrashed",
|
|
10127
|
+
message
|
|
10128
|
+
});
|
|
10129
|
+
}
|
|
10130
|
+
});
|
|
10131
|
+
session.browser = browser;
|
|
10132
|
+
await browser.launch();
|
|
10133
|
+
if (session.browser !== browser || session.generation !== runtimeGeneration || session.localOwner) {
|
|
10134
|
+
await browser.close();
|
|
10135
|
+
throw new Error("The Browser runtime changed during launch.");
|
|
10136
|
+
}
|
|
10137
|
+
session.state = browser.state;
|
|
10138
|
+
if (restoredUrl !== "about:blank") session.state = await browser.command({
|
|
10139
|
+
type: "navigate",
|
|
10140
|
+
url: restoredUrl
|
|
10141
|
+
}).then(() => true, () => false) ? browser.state : {
|
|
10142
|
+
...browser.state,
|
|
10143
|
+
url: restoredUrl,
|
|
10144
|
+
title: restoredTitle
|
|
10145
|
+
};
|
|
10146
|
+
runtimeReady = true;
|
|
10147
|
+
session.state = browser.state;
|
|
10148
|
+
this.queuePanelState(session, session.state);
|
|
10149
|
+
this.broadcastState(session);
|
|
10150
|
+
return browser;
|
|
10151
|
+
})();
|
|
10152
|
+
session.launch = launch;
|
|
10153
|
+
launch.catch(() => {
|
|
10154
|
+
if (session.launch === launch) {
|
|
10155
|
+
session.launch = null;
|
|
10156
|
+
session.browser = null;
|
|
10157
|
+
}
|
|
10158
|
+
});
|
|
10159
|
+
return launch;
|
|
10160
|
+
}
|
|
10161
|
+
browserFor(session) {
|
|
10162
|
+
return this.ensurePlaywrightRuntime(session);
|
|
10163
|
+
}
|
|
10164
|
+
async getSession(panelId) {
|
|
10165
|
+
const existing = this.sessions.get(panelId);
|
|
10166
|
+
if (existing) {
|
|
10167
|
+
await this.service.authorizeBrowserPanel(panelId);
|
|
10168
|
+
return existing;
|
|
10169
|
+
}
|
|
10170
|
+
const pending = this.sessionCreations.get(panelId);
|
|
10171
|
+
if (pending) return pending;
|
|
10172
|
+
const creation = this.createSession(panelId).finally(() => {
|
|
10173
|
+
if (this.sessionCreations.get(panelId) === creation) this.sessionCreations.delete(panelId);
|
|
10174
|
+
});
|
|
10175
|
+
this.sessionCreations.set(panelId, creation);
|
|
10176
|
+
return creation;
|
|
10177
|
+
}
|
|
10178
|
+
async accept(ticketValue, transport) {
|
|
10179
|
+
const ticket = this.tickets.get(ticketValue);
|
|
10180
|
+
this.tickets.delete(ticketValue);
|
|
10181
|
+
if (!ticket || ticket.expiresAt < Date.now()) throw new Error("INVALID_BROWSER_TICKET");
|
|
10182
|
+
const session = await this.getSession(ticket.panelId);
|
|
10183
|
+
const supersededAttachments = [...session.attachments.values()].filter((candidate) => candidate.clientId === ticket.clientId);
|
|
10184
|
+
if (session.attachments.size - supersededAttachments.length >= MAX_BROWSER_ATTACHMENTS) {
|
|
10185
|
+
transport.sendMessage({
|
|
10186
|
+
type: "browserUnavailable",
|
|
10187
|
+
message: "This Browser already has the maximum of eight attached clients.",
|
|
10188
|
+
installCommand: null
|
|
10189
|
+
});
|
|
10190
|
+
return transport.id;
|
|
10191
|
+
}
|
|
10192
|
+
const attachment = {
|
|
10193
|
+
id: transport.id,
|
|
10194
|
+
clientId: ticket.clientId,
|
|
10195
|
+
transport,
|
|
10196
|
+
visible: ticket.visible,
|
|
10197
|
+
closing: false,
|
|
10198
|
+
awaitingFrame: null,
|
|
10199
|
+
pendingFrame: null,
|
|
10200
|
+
viewport: { ...session.state.viewport }
|
|
10201
|
+
};
|
|
10202
|
+
session.attachments.set(attachment.id, attachment);
|
|
10203
|
+
try {
|
|
10204
|
+
if (!session.localOwner) await this.browserFor(session);
|
|
10205
|
+
if (!transport.isConnected()) {
|
|
10206
|
+
this.close(attachment.id);
|
|
10207
|
+
return attachment.id;
|
|
10208
|
+
}
|
|
10209
|
+
await this.scheduleOperation(session, async () => {
|
|
10210
|
+
if (attachment.closing) return;
|
|
10211
|
+
for (const superseded of supersededAttachments) {
|
|
10212
|
+
if (session.attachments.get(superseded.id) !== superseded) continue;
|
|
10213
|
+
superseded.closing = true;
|
|
10214
|
+
session.attachments.delete(superseded.id);
|
|
10215
|
+
superseded.transport.disconnect();
|
|
10216
|
+
}
|
|
10217
|
+
if (attachment.visible && session.controllerId === null) session.controllerId = attachmentController(attachment.clientId);
|
|
10218
|
+
await this.updateScreencast(session);
|
|
10219
|
+
}, { required: true });
|
|
10220
|
+
transport.sendMessage({
|
|
10221
|
+
type: "ready",
|
|
10222
|
+
state: this.stateFor(session, attachment)
|
|
10223
|
+
});
|
|
10224
|
+
this.broadcastState(session, "controlChanged");
|
|
10225
|
+
if (session.crashMessage) transport.sendMessage({
|
|
10226
|
+
type: "browserCrashed",
|
|
10227
|
+
message: session.crashMessage
|
|
10228
|
+
});
|
|
10229
|
+
this.sendLatestFrame(session, attachment);
|
|
10230
|
+
} catch (error) {
|
|
10231
|
+
attachment.closing = true;
|
|
10232
|
+
session.attachments.delete(attachment.id);
|
|
10233
|
+
if (session.controllerId === attachmentController(attachment.clientId) && ![...session.attachments.values()].some((candidate) => candidate.clientId === attachment.clientId)) session.controllerId = session.localOwner ? LOCAL_BROWSER_OWNER_CONTROLLER : null;
|
|
10234
|
+
transport.sendMessage({
|
|
10235
|
+
type: "browserUnavailable",
|
|
10236
|
+
message: error instanceof Error ? error.message : String(error),
|
|
10237
|
+
installCommand: session.localOwner ? null : "treeport browser install"
|
|
10238
|
+
});
|
|
10239
|
+
if (!session.localOwner) await this.closePlaywrightRuntime(session).catch(() => void 0);
|
|
10240
|
+
}
|
|
10241
|
+
return attachment.id;
|
|
10242
|
+
}
|
|
10243
|
+
async acceptOwner(auth, transport) {
|
|
10244
|
+
const ticket = this.ownerTickets.get(auth.ticket);
|
|
10245
|
+
this.ownerTickets.delete(auth.ticket);
|
|
10246
|
+
if (!ticket || ticket.expiresAt < Date.now() || ticket.challenge !== auth.challenge || !browserOwnerEndpointSchema.safeParse(auth.endpoint).success) throw new Error("INVALID_BROWSER_OWNER_TICKET");
|
|
10247
|
+
const identityResponse = await fetch(new URL("identity", auth.endpoint), {
|
|
10248
|
+
signal: AbortSignal.timeout(3e3),
|
|
10249
|
+
redirect: "error"
|
|
10250
|
+
});
|
|
10251
|
+
const identity = z.strictObject({
|
|
10252
|
+
panelId: z.string().min(1).max(128),
|
|
10253
|
+
challenge: z.string().min(32).max(256)
|
|
10254
|
+
}).parse(await identityResponse.json());
|
|
10255
|
+
if (!identityResponse.ok || identity.panelId !== ticket.panelId || identity.challenge !== ticket.challenge) throw new Error("INVALID_BROWSER_OWNER_IDENTITY");
|
|
10256
|
+
const session = await this.getSession(ticket.panelId);
|
|
10257
|
+
await this.scheduleOperation(session, async () => {
|
|
10258
|
+
const previousOwner = session.localOwner;
|
|
10259
|
+
if (previousOwner?.transport.isConnected()) {
|
|
10260
|
+
transport.send({
|
|
10261
|
+
type: "claimRejected",
|
|
10262
|
+
message: "This Browser is open in another local desktop app."
|
|
10263
|
+
});
|
|
10264
|
+
transport.disconnect();
|
|
10265
|
+
return;
|
|
10266
|
+
}
|
|
10267
|
+
if ([...this.sessions.values()].filter((candidate) => candidate !== session && (candidate.localOwner !== null || candidate.launch !== null)).length >= 6) {
|
|
10268
|
+
transport.send({
|
|
10269
|
+
type: "claimRejected",
|
|
10270
|
+
message: "Treeport supports at most six active Browser sessions. Close one and try again."
|
|
10271
|
+
});
|
|
10272
|
+
transport.disconnect();
|
|
10273
|
+
return;
|
|
10274
|
+
}
|
|
10275
|
+
let resolveReady = null;
|
|
10276
|
+
const readyPromise = new Promise((resolve) => {
|
|
10277
|
+
resolveReady = resolve;
|
|
10278
|
+
});
|
|
10279
|
+
const resumed = previousOwner?.clientId === ticket.clientId;
|
|
10280
|
+
let owner;
|
|
10281
|
+
if (previousOwner) {
|
|
10282
|
+
previousOwner.resolveReady?.();
|
|
10283
|
+
for (const request of previousOwner.requests.values()) {
|
|
10284
|
+
clearTimeout(request.timer);
|
|
10285
|
+
request.resolve(false);
|
|
10286
|
+
}
|
|
10287
|
+
previousOwner.requests.clear();
|
|
10288
|
+
if (previousOwner.endpoint !== auth.endpoint) {
|
|
10289
|
+
await this.closeLocalAutomation(session);
|
|
10290
|
+
session.latestFrame = null;
|
|
10291
|
+
session.sequence = 0;
|
|
10292
|
+
}
|
|
10293
|
+
previousOwner.transport = transport;
|
|
10294
|
+
previousOwner.clientId = ticket.clientId;
|
|
10295
|
+
previousOwner.endpoint = auth.endpoint;
|
|
10296
|
+
previousOwner.challenge = auth.challenge;
|
|
10297
|
+
previousOwner.generation = resumed ? previousOwner.generation : ++session.generation;
|
|
10298
|
+
previousOwner.revision = -1;
|
|
10299
|
+
previousOwner.ready = false;
|
|
10300
|
+
previousOwner.controller = "none";
|
|
10301
|
+
previousOwner.retainPaint = false;
|
|
10302
|
+
previousOwner.readyPromise = readyPromise;
|
|
10303
|
+
previousOwner.resolveReady = resolveReady;
|
|
10304
|
+
owner = previousOwner;
|
|
10305
|
+
} else {
|
|
10306
|
+
await this.closePlaywrightRuntime(session);
|
|
10307
|
+
owner = {
|
|
10308
|
+
transport,
|
|
10309
|
+
clientId: ticket.clientId,
|
|
10310
|
+
endpoint: auth.endpoint,
|
|
10311
|
+
challenge: auth.challenge,
|
|
10312
|
+
generation: ++session.generation,
|
|
10313
|
+
revision: -1,
|
|
10314
|
+
ready: false,
|
|
10315
|
+
controller: "none",
|
|
10316
|
+
retainPaint: false,
|
|
10317
|
+
readyPromise,
|
|
10318
|
+
resolveReady,
|
|
10319
|
+
requests: /* @__PURE__ */ new Map()
|
|
10320
|
+
};
|
|
10321
|
+
session.localOwner = owner;
|
|
10322
|
+
session.controllerId = LOCAL_BROWSER_OWNER_CONTROLLER;
|
|
10323
|
+
session.latestFrame = null;
|
|
10324
|
+
session.sequence = 0;
|
|
10325
|
+
}
|
|
10326
|
+
if (!transport.isConnected()) return;
|
|
10327
|
+
session.controllerId ??= LOCAL_BROWSER_OWNER_CONTROLLER;
|
|
10328
|
+
transport.send({
|
|
10329
|
+
type: "claimGranted",
|
|
10330
|
+
panelId: session.panelId,
|
|
10331
|
+
generation: owner.generation,
|
|
10332
|
+
resumed,
|
|
10333
|
+
state: session.state
|
|
10334
|
+
});
|
|
10335
|
+
if (!previousOwner) this.broadcastState(session, "controlChanged");
|
|
10336
|
+
}, { required: true });
|
|
10337
|
+
return transport.id;
|
|
10338
|
+
}
|
|
10339
|
+
ownerMessage(connectionId, value) {
|
|
10340
|
+
const session = [...this.sessions.values()].find((candidate) => candidate.localOwner?.transport.id === connectionId);
|
|
10341
|
+
const message = parseBrowserOwnerClientMessage(value);
|
|
10342
|
+
const owner = session?.localOwner;
|
|
10343
|
+
if (!session || !owner || !message) {
|
|
10344
|
+
owner?.transport.disconnect();
|
|
10345
|
+
return;
|
|
10346
|
+
}
|
|
10347
|
+
if (message.generation !== owner.generation) return;
|
|
10348
|
+
if (message.type === "ready" || message.type === "state") {
|
|
10349
|
+
if (message.revision <= owner.revision) return;
|
|
10350
|
+
owner.revision = message.revision;
|
|
10351
|
+
session.state = message.state;
|
|
10352
|
+
this.queuePanelState(session, message.state);
|
|
10353
|
+
this.broadcastState(session);
|
|
10354
|
+
if (message.type === "ready" && !owner.ready) {
|
|
10355
|
+
owner.ready = true;
|
|
10356
|
+
owner.resolveReady?.();
|
|
10357
|
+
owner.resolveReady = null;
|
|
10358
|
+
this.scheduleOperation(session, () => this.updateScreencast(session), { required: true }).catch((cause) => this.broadcastNavigationError(session, cause instanceof Error ? cause.message : String(cause)));
|
|
10359
|
+
}
|
|
10360
|
+
return;
|
|
10361
|
+
}
|
|
10362
|
+
if (message.type === "takeControl") {
|
|
10363
|
+
this.scheduleOperation(session, async () => {
|
|
10364
|
+
if (session.localOwner !== owner) return;
|
|
10365
|
+
await this.updateScreencast(session, LOCAL_BROWSER_OWNER_CONTROLLER);
|
|
10366
|
+
if (session.localOwner !== owner) throw new Error("The local Browser owner changed.");
|
|
10367
|
+
session.controllerId = LOCAL_BROWSER_OWNER_CONTROLLER;
|
|
10368
|
+
this.broadcastState(session, "controlChanged");
|
|
10369
|
+
}, { required: true }).catch(() => void 0);
|
|
10370
|
+
return;
|
|
10371
|
+
}
|
|
10372
|
+
if (message.type === "released") {
|
|
10373
|
+
this.scheduleOperation(session, () => this.releaseLocalOwner(session, owner), { required: true }).catch((cause) => this.broadcastNavigationError(session, cause instanceof Error ? cause.message : String(cause)));
|
|
10374
|
+
return;
|
|
10375
|
+
}
|
|
10376
|
+
if (message.type === "popup") {
|
|
10377
|
+
this.openPopup(session, message.url);
|
|
10378
|
+
return;
|
|
10379
|
+
}
|
|
10380
|
+
if (message.type === "crashed") {
|
|
10381
|
+
session.crashMessage = message.message;
|
|
10382
|
+
for (const attachment of session.attachments.values()) attachment.transport.sendMessage({
|
|
10383
|
+
type: "browserCrashed",
|
|
10384
|
+
message: message.message
|
|
10385
|
+
});
|
|
10386
|
+
return;
|
|
10387
|
+
}
|
|
10388
|
+
const request = owner.requests.get(message.requestId);
|
|
10389
|
+
if (!request) return;
|
|
10390
|
+
owner.requests.delete(message.requestId);
|
|
10391
|
+
clearTimeout(request.timer);
|
|
10392
|
+
request.resolve(message.type === "runtimeControlResult" ? message.accepted : message.canClose);
|
|
10393
|
+
}
|
|
10394
|
+
closeOwner(connectionId) {
|
|
10395
|
+
const session = [...this.sessions.values()].find((candidate) => candidate.localOwner?.transport.id === connectionId);
|
|
10396
|
+
const owner = session?.localOwner;
|
|
10397
|
+
if (!session || !owner) return;
|
|
10398
|
+
owner.ready = false;
|
|
10399
|
+
owner.resolveReady?.();
|
|
10400
|
+
owner.resolveReady = null;
|
|
10401
|
+
for (const request of owner.requests.values()) {
|
|
10402
|
+
clearTimeout(request.timer);
|
|
10403
|
+
request.resolve(false);
|
|
10404
|
+
}
|
|
10405
|
+
owner.requests.clear();
|
|
10406
|
+
}
|
|
10407
|
+
async releaseLocalOwner(session, owner) {
|
|
10408
|
+
if (session.localOwner !== owner) return;
|
|
10409
|
+
session.localOwner = null;
|
|
10410
|
+
owner.ready = false;
|
|
10411
|
+
owner.resolveReady?.();
|
|
10412
|
+
owner.resolveReady = null;
|
|
10413
|
+
for (const request of owner.requests.values()) {
|
|
10414
|
+
clearTimeout(request.timer);
|
|
10415
|
+
request.resolve(false);
|
|
10416
|
+
}
|
|
10417
|
+
owner.requests.clear();
|
|
10418
|
+
await this.closeLocalAutomation(session);
|
|
10419
|
+
session.generation += 1;
|
|
10420
|
+
session.latestFrame = null;
|
|
10421
|
+
session.sequence = 0;
|
|
10422
|
+
if (session.controllerId === LOCAL_BROWSER_OWNER_CONTROLLER || session.controllerId === "agent") {
|
|
10423
|
+
const attachment = [...session.attachments.values()].find((candidate) => !candidate.closing && candidate.visible && candidate.transport.isConnected());
|
|
10424
|
+
session.controllerId = attachment ? attachmentController(attachment.clientId) : null;
|
|
10425
|
+
}
|
|
10426
|
+
session.agentProcess?.kill("SIGTERM");
|
|
10427
|
+
session.agentProcess = null;
|
|
10428
|
+
await this.detachAgent(session);
|
|
10429
|
+
this.broadcastState(session, "controlChanged");
|
|
10430
|
+
if (![...session.attachments.values()].some((attachment) => !attachment.closing && attachment.visible && attachment.transport.isConnected())) return;
|
|
10431
|
+
const runtimeError = await this.browserFor(session).then(() => this.updateScreencast(session)).then(() => null, (cause) => cause);
|
|
10432
|
+
if (runtimeError !== null) {
|
|
10433
|
+
for (const attachment of session.attachments.values()) attachment.transport.sendMessage({
|
|
10434
|
+
type: "browserUnavailable",
|
|
10435
|
+
message: runtimeError instanceof Error ? runtimeError.message : String(runtimeError),
|
|
10436
|
+
installCommand: "treeport browser install"
|
|
10437
|
+
});
|
|
10438
|
+
await this.closePlaywrightRuntime(session).catch(() => void 0);
|
|
10439
|
+
}
|
|
10440
|
+
}
|
|
10441
|
+
requestLocalOwner(owner, message) {
|
|
10442
|
+
if (!owner.transport.isConnected()) return Promise.resolve(false);
|
|
10443
|
+
const requestId = crypto.randomUUID();
|
|
10444
|
+
return new Promise((resolve) => {
|
|
10445
|
+
const timer = setTimeout(() => {
|
|
10446
|
+
owner.requests.delete(requestId);
|
|
10447
|
+
resolve(false);
|
|
10448
|
+
}, 5e3);
|
|
10449
|
+
timer.unref();
|
|
10450
|
+
owner.requests.set(requestId, {
|
|
10451
|
+
resolve,
|
|
10452
|
+
timer
|
|
10453
|
+
});
|
|
10454
|
+
if (!owner.transport.send({
|
|
10455
|
+
...message,
|
|
10456
|
+
generation: owner.generation,
|
|
10457
|
+
requestId
|
|
10458
|
+
})) {
|
|
10459
|
+
clearTimeout(timer);
|
|
10460
|
+
owner.requests.delete(requestId);
|
|
10461
|
+
resolve(false);
|
|
10462
|
+
}
|
|
10463
|
+
});
|
|
10464
|
+
}
|
|
10465
|
+
async setLocalOwnerRuntimeControl(session, owner, controller, retainPaint) {
|
|
10466
|
+
if (owner.controller === controller && owner.retainPaint === retainPaint) return;
|
|
10467
|
+
if (!await this.requestLocalOwner(owner, {
|
|
10468
|
+
type: "runtimeControl",
|
|
10469
|
+
controller,
|
|
10470
|
+
retainPaint
|
|
10471
|
+
}) || session.localOwner !== owner || session.generation !== owner.generation) throw new Error("The local Browser owner did not accept control.");
|
|
10472
|
+
owner.controller = controller;
|
|
10473
|
+
owner.retainPaint = retainPaint;
|
|
10474
|
+
}
|
|
10475
|
+
sendLatestFrame(session, attachment) {
|
|
10476
|
+
const frame = session.latestFrame;
|
|
10477
|
+
if (!frame || !attachment.visible || attachment.awaitingFrame !== null || !attachment.transport.isConnected()) return;
|
|
10478
|
+
if (attachment.transport.sendFrame(frame)) attachment.awaitingFrame = frame.sequence;
|
|
10479
|
+
}
|
|
10480
|
+
publishFrame(session, value) {
|
|
10481
|
+
if (value.data.byteLength > 8388608) return;
|
|
10482
|
+
const frame = {
|
|
10483
|
+
...value,
|
|
10484
|
+
sequence: ++session.sequence
|
|
10485
|
+
};
|
|
10486
|
+
session.latestFrame = frame;
|
|
10487
|
+
for (const attachment of session.attachments.values()) {
|
|
10488
|
+
if (!attachment.visible || !attachment.transport.isConnected()) continue;
|
|
10489
|
+
if (attachment.awaitingFrame !== null) {
|
|
10490
|
+
attachment.pendingFrame = frame;
|
|
10491
|
+
continue;
|
|
10492
|
+
}
|
|
10493
|
+
if (attachment.transport.sendFrame(frame)) attachment.awaitingFrame = frame.sequence;
|
|
10494
|
+
}
|
|
10495
|
+
}
|
|
10496
|
+
message(connectionId, value) {
|
|
10497
|
+
const entry = [...this.sessions.values()].map((session) => ({
|
|
10498
|
+
session,
|
|
10499
|
+
attachment: session.attachments.get(connectionId)
|
|
10500
|
+
})).find((candidate) => candidate.attachment !== void 0);
|
|
10501
|
+
if (!entry) return;
|
|
10502
|
+
const message = parseBrowserClientMessage(value);
|
|
10503
|
+
if (!message) {
|
|
10504
|
+
entry.attachment.transport.disconnect();
|
|
10505
|
+
return;
|
|
10506
|
+
}
|
|
10507
|
+
const { session, attachment } = entry;
|
|
10508
|
+
if (attachment.closing) return;
|
|
10509
|
+
if (message.type === "frameAck") {
|
|
10510
|
+
if (attachment.awaitingFrame !== message.sequence) return;
|
|
10511
|
+
attachment.awaitingFrame = null;
|
|
10512
|
+
const pending = attachment.pendingFrame;
|
|
10513
|
+
attachment.pendingFrame = null;
|
|
10514
|
+
if (pending && attachment.transport.sendFrame(pending)) attachment.awaitingFrame = pending.sequence;
|
|
10515
|
+
return;
|
|
10516
|
+
}
|
|
10517
|
+
if (message.type === "setVisible") {
|
|
10518
|
+
attachment.visible = message.visible;
|
|
10519
|
+
if (message.visible) this.sendLatestFrame(session, attachment);
|
|
10520
|
+
else {
|
|
10521
|
+
attachment.awaitingFrame = null;
|
|
10522
|
+
attachment.pendingFrame = null;
|
|
10523
|
+
}
|
|
10524
|
+
this.queueClientOperation(session, attachment, {
|
|
10525
|
+
coalesceKey: `screencast:${session.panelId}`,
|
|
10526
|
+
message: null,
|
|
10527
|
+
execute: async () => {
|
|
10528
|
+
const previousController = session.controllerId;
|
|
10529
|
+
let nextController = previousController;
|
|
10530
|
+
if (attachment.visible && previousController === null) nextController = attachmentController(attachment.clientId);
|
|
10531
|
+
else if (!attachment.visible && previousController === attachmentController(attachment.clientId)) {
|
|
10532
|
+
const nextAttachment = [...session.attachments.values()].find((candidate) => candidate.id !== attachment.id && !candidate.closing && candidate.visible);
|
|
10533
|
+
nextController = nextAttachment ? attachmentController(nextAttachment.clientId) : session.localOwner ? LOCAL_BROWSER_OWNER_CONTROLLER : null;
|
|
10534
|
+
}
|
|
10535
|
+
await this.updateScreencast(session, nextController);
|
|
10536
|
+
session.controllerId = nextController;
|
|
10537
|
+
if (nextController !== previousController) this.broadcastState(session, "controlChanged");
|
|
10538
|
+
},
|
|
10539
|
+
required: true
|
|
10540
|
+
});
|
|
10541
|
+
return;
|
|
10542
|
+
}
|
|
10543
|
+
if (message.type === "resize") {
|
|
10544
|
+
attachment.viewport = {
|
|
10545
|
+
width: message.width,
|
|
10546
|
+
height: message.height
|
|
10547
|
+
};
|
|
10548
|
+
if (session.localOwner) return;
|
|
10549
|
+
this.queueClientOperation(session, attachment, {
|
|
10550
|
+
coalesceKey: `resize:${attachment.id}`,
|
|
10551
|
+
message,
|
|
10552
|
+
execute: async (queuedMessage) => {
|
|
10553
|
+
if (queuedMessage?.type !== "resize" || attachment.closing || session.attachments.get(attachment.id) !== attachment || session.controllerId !== attachmentController(attachment.clientId)) return;
|
|
10554
|
+
try {
|
|
10555
|
+
await (await this.browserFor(session)).command(queuedMessage);
|
|
10556
|
+
} catch (cause) {
|
|
10557
|
+
attachment.transport.sendMessage({
|
|
10558
|
+
type: "navigationError",
|
|
10559
|
+
message: cause instanceof Error ? cause.message : String(cause)
|
|
10560
|
+
});
|
|
10561
|
+
}
|
|
10562
|
+
},
|
|
10563
|
+
required: false
|
|
10564
|
+
});
|
|
10565
|
+
return;
|
|
10566
|
+
}
|
|
10567
|
+
if (message.type === "takeControl") {
|
|
10568
|
+
this.queueClientOperation(session, attachment, {
|
|
10569
|
+
coalesceKey: null,
|
|
10570
|
+
message: null,
|
|
10571
|
+
execute: async () => {
|
|
10572
|
+
if (attachment.closing || session.attachments.get(attachment.id) !== attachment) return;
|
|
10573
|
+
const previousController = session.controllerId;
|
|
10574
|
+
const nextController = attachmentController(attachment.clientId);
|
|
10575
|
+
try {
|
|
10576
|
+
if (session.localOwner) await this.updateScreencast(session, nextController);
|
|
10577
|
+
else await (await this.browserFor(session)).command({
|
|
10578
|
+
type: "resize",
|
|
10579
|
+
...attachment.viewport
|
|
10580
|
+
});
|
|
10581
|
+
session.controllerId = nextController;
|
|
10582
|
+
if (previousController !== nextController) this.broadcastState(session, "controlChanged");
|
|
10583
|
+
} catch (cause) {
|
|
10584
|
+
attachment.transport.sendMessage({
|
|
10585
|
+
type: "navigationError",
|
|
10586
|
+
message: cause instanceof Error ? cause.message : String(cause)
|
|
10587
|
+
});
|
|
10588
|
+
}
|
|
10589
|
+
},
|
|
10590
|
+
required: false
|
|
10591
|
+
});
|
|
10592
|
+
return;
|
|
10593
|
+
}
|
|
10594
|
+
const coalesceKey = message.type === "pointer" && message.phase === "move" ? `pointer-move:${attachment.id}` : message.type === "wheel" ? `wheel:${attachment.id}` : null;
|
|
10595
|
+
this.queueClientOperation(session, attachment, {
|
|
10596
|
+
coalesceKey,
|
|
10597
|
+
message,
|
|
10598
|
+
execute: async (queuedMessage) => {
|
|
10599
|
+
if (!queuedMessage || attachment.closing || session.attachments.get(attachment.id) !== attachment) return;
|
|
10600
|
+
if (session.controllerId !== attachmentController(attachment.clientId)) {
|
|
10601
|
+
attachment.transport.sendMessage({
|
|
10602
|
+
type: "navigationError",
|
|
10603
|
+
message: "Take control before you interact with this browser."
|
|
10604
|
+
});
|
|
10605
|
+
return;
|
|
10606
|
+
}
|
|
10607
|
+
try {
|
|
10608
|
+
const localOwner = session.localOwner;
|
|
10609
|
+
if (localOwner) await this.executeLocalClientCommand(session, localOwner, queuedMessage);
|
|
10610
|
+
else {
|
|
10611
|
+
const browser = await this.browserFor(session);
|
|
10612
|
+
await browser.command(queuedMessage);
|
|
10613
|
+
this.queuePanelState(session, browser.state);
|
|
10614
|
+
await this.waitForPanelState(session);
|
|
10615
|
+
}
|
|
10616
|
+
} catch (cause) {
|
|
10617
|
+
attachment.transport.sendMessage({
|
|
10618
|
+
type: "navigationError",
|
|
10619
|
+
message: cause instanceof Error ? cause.message : String(cause)
|
|
10620
|
+
});
|
|
10621
|
+
}
|
|
10622
|
+
},
|
|
10623
|
+
required: false
|
|
10624
|
+
});
|
|
10625
|
+
}
|
|
10626
|
+
async updateScreencast(session, controllerId = session.controllerId) {
|
|
10627
|
+
const visible = [...session.attachments.values()].some((attachment) => !attachment.closing && attachment.visible && attachment.transport.isConnected());
|
|
10628
|
+
const localOwner = session.localOwner;
|
|
10629
|
+
if (localOwner) {
|
|
10630
|
+
const controller = controllerId === "agent" ? "agent" : controllerId && controllerId !== LOCAL_BROWSER_OWNER_CONTROLLER ? "other" : "none";
|
|
10631
|
+
const retainPaint = visible || controller === "agent";
|
|
10632
|
+
if (visible) {
|
|
10633
|
+
await this.setLocalOwnerRuntimeControl(session, localOwner, controller, retainPaint);
|
|
10634
|
+
await this.setLocalScreencasting(session, localOwner, true);
|
|
10635
|
+
} else {
|
|
10636
|
+
await this.setLocalScreencasting(session, localOwner, false);
|
|
10637
|
+
await this.setLocalOwnerRuntimeControl(session, localOwner, controller, retainPaint);
|
|
10638
|
+
}
|
|
10639
|
+
return;
|
|
10640
|
+
}
|
|
10641
|
+
if (!session.launch) return;
|
|
10642
|
+
await (await session.launch.catch(() => null))?.setScreencasting(visible).catch(() => void 0);
|
|
10643
|
+
}
|
|
10644
|
+
async detachAgent(session) {
|
|
10645
|
+
const name = session.agentSessionName;
|
|
10646
|
+
if (!session.agentAttached || !name) return;
|
|
10647
|
+
session.agentAttached = false;
|
|
10648
|
+
session.agentSessionName = null;
|
|
10649
|
+
await this.executeAgentCli(session, [`-s=${name}`, "detach"]).catch(() => void 0);
|
|
10650
|
+
}
|
|
10651
|
+
async closePlaywrightRuntime(session) {
|
|
10652
|
+
await this.waitForPanelState(session);
|
|
10653
|
+
await this.detachAgent(session);
|
|
10654
|
+
const launch = session.launch;
|
|
10655
|
+
await (await launch?.catch(() => session.browser))?.close();
|
|
10656
|
+
if (session.launch === launch) {
|
|
10657
|
+
session.launch = null;
|
|
10658
|
+
session.browser = null;
|
|
10659
|
+
}
|
|
10660
|
+
session.latestFrame = null;
|
|
10661
|
+
session.sequence = 0;
|
|
10662
|
+
await fs.rm(session.agentDirectory, {
|
|
10663
|
+
recursive: true,
|
|
10664
|
+
force: true
|
|
10665
|
+
});
|
|
10666
|
+
await fs.mkdir(session.agentDirectory, {
|
|
10667
|
+
recursive: true,
|
|
10668
|
+
mode: 448
|
|
10669
|
+
});
|
|
10670
|
+
await fs.chmod(session.agentDirectory, 448);
|
|
10671
|
+
}
|
|
10672
|
+
async destroySession(session, reason) {
|
|
10673
|
+
await this.waitForPanelState(session);
|
|
10674
|
+
if (session.localOwner) {
|
|
10675
|
+
const owner = session.localOwner;
|
|
10676
|
+
session.localOwner = null;
|
|
10677
|
+
owner.resolveReady?.();
|
|
10678
|
+
owner.resolveReady = null;
|
|
10679
|
+
await this.closeLocalAutomation(session);
|
|
10680
|
+
for (const request of owner.requests.values()) {
|
|
10681
|
+
clearTimeout(request.timer);
|
|
10682
|
+
request.resolve(false);
|
|
10683
|
+
}
|
|
10684
|
+
owner.requests.clear();
|
|
10685
|
+
owner.transport.send({
|
|
10686
|
+
type: "closed",
|
|
10687
|
+
reason
|
|
10688
|
+
});
|
|
10689
|
+
owner.transport.disconnect();
|
|
10690
|
+
}
|
|
10691
|
+
if (session.launch || session.browser) await this.closePlaywrightRuntime(session);
|
|
10692
|
+
else await this.detachAgent(session);
|
|
10693
|
+
await fs.rm(session.agentDirectory, {
|
|
10694
|
+
recursive: true,
|
|
10695
|
+
force: true
|
|
10696
|
+
});
|
|
10697
|
+
if (this.sessions.get(session.panelId) === session) this.sessions.delete(session.panelId);
|
|
10698
|
+
for (const attachment of session.attachments.values()) {
|
|
10699
|
+
attachment.closing = true;
|
|
10700
|
+
attachment.transport.sendMessage({
|
|
10701
|
+
type: "closed",
|
|
10702
|
+
reason
|
|
10703
|
+
});
|
|
10704
|
+
attachment.transport.disconnect();
|
|
10705
|
+
}
|
|
10706
|
+
}
|
|
10707
|
+
close(connectionId) {
|
|
10708
|
+
for (const session of this.sessions.values()) {
|
|
10709
|
+
const attachment = session.attachments.get(connectionId);
|
|
10710
|
+
if (!attachment || attachment.closing) continue;
|
|
10711
|
+
attachment.closing = true;
|
|
10712
|
+
this.scheduleOperation(session, async () => {
|
|
10713
|
+
session.attachments.delete(connectionId);
|
|
10714
|
+
await this.updateScreencast(session);
|
|
10715
|
+
}, { required: true }).catch(() => void 0);
|
|
10716
|
+
return;
|
|
10717
|
+
}
|
|
10718
|
+
}
|
|
10719
|
+
async requestPanelClose(panelId, force = false) {
|
|
10720
|
+
const session = this.sessions.get(panelId) ?? await this.sessionCreations.get(panelId)?.catch(() => null);
|
|
10721
|
+
if (!session) return true;
|
|
10722
|
+
let canClose = false;
|
|
10723
|
+
await this.scheduleOperation(session, async () => {
|
|
10724
|
+
if (session.localOwner) canClose = await this.requestLocalOwner(session.localOwner, {
|
|
10725
|
+
type: "closeRequest",
|
|
10726
|
+
force
|
|
10727
|
+
});
|
|
10728
|
+
else if (session.launch || session.browser) canClose = await (await (session.launch ?? Promise.resolve(session.browser))).requestClose(force);
|
|
10729
|
+
else canClose = true;
|
|
10730
|
+
if (canClose) {
|
|
10731
|
+
session.closing = true;
|
|
10732
|
+
this.stopScheduler(session, "Browser closed.");
|
|
10733
|
+
}
|
|
10734
|
+
}, { required: true });
|
|
10735
|
+
return canClose;
|
|
10736
|
+
}
|
|
10737
|
+
async closePanel(panelId, reason) {
|
|
10738
|
+
const session = this.sessions.get(panelId) ?? await this.sessionCreations.get(panelId)?.catch(() => null);
|
|
10739
|
+
if (!session) return;
|
|
10740
|
+
if (session.closeOperation) return session.closeOperation;
|
|
10741
|
+
session.closing = true;
|
|
10742
|
+
this.stopScheduler(session, reason);
|
|
10743
|
+
session.agentProcess?.kill("SIGTERM");
|
|
10744
|
+
const closeOperation = this.scheduleOperation(session, () => this.destroySession(session, reason), {
|
|
10745
|
+
required: true,
|
|
10746
|
+
allowWhenClosing: true
|
|
10747
|
+
});
|
|
10748
|
+
session.closeOperation = closeOperation;
|
|
10749
|
+
return closeOperation;
|
|
10750
|
+
}
|
|
10751
|
+
async playwrightCliPath() {
|
|
10752
|
+
const packageJsonPath = createRequire(import.meta.url).resolve("@playwright/cli/package.json");
|
|
10753
|
+
return path.join(path.dirname(packageJsonPath), "playwright-cli.js");
|
|
10754
|
+
}
|
|
10755
|
+
executeAgentCli(session, args) {
|
|
10756
|
+
if (this.agentCliRunner) return this.agentCliRunner({
|
|
10757
|
+
panelId: session.panelId,
|
|
10758
|
+
agentDirectory: session.agentDirectory
|
|
10759
|
+
}, args);
|
|
10760
|
+
return this.runAgentCli(session, args);
|
|
10761
|
+
}
|
|
10762
|
+
async runAgentCli(session, args) {
|
|
10763
|
+
const cli = await this.playwrightCliPath();
|
|
10764
|
+
return new Promise((resolve, reject) => {
|
|
10765
|
+
const child = spawn(process.execPath, [cli, ...args], {
|
|
10766
|
+
cwd: session.agentDirectory,
|
|
10767
|
+
env: process.env,
|
|
10768
|
+
stdio: [
|
|
10769
|
+
"ignore",
|
|
10770
|
+
"pipe",
|
|
10771
|
+
"pipe"
|
|
10772
|
+
],
|
|
10773
|
+
timeout: 12e4,
|
|
10774
|
+
killSignal: "SIGTERM"
|
|
10775
|
+
});
|
|
10776
|
+
session.agentProcess = child;
|
|
10777
|
+
let output = "";
|
|
10778
|
+
const append = (data) => {
|
|
10779
|
+
if (output.length < 10 * 1024 * 1024) output += String(data);
|
|
10780
|
+
};
|
|
10781
|
+
child.stdout.on("data", append);
|
|
10782
|
+
child.stderr.on("data", append);
|
|
10783
|
+
child.once("error", (error) => {
|
|
10784
|
+
if (session.agentProcess === child) session.agentProcess = null;
|
|
10785
|
+
reject(error);
|
|
10786
|
+
});
|
|
10787
|
+
child.once("exit", (code) => {
|
|
10788
|
+
if (session.agentProcess === child) session.agentProcess = null;
|
|
10789
|
+
if (code === 0) resolve(output.trim());
|
|
10790
|
+
else reject(new Error(output.trim() || `Playwright Agent CLI exited with ${code}`));
|
|
10791
|
+
});
|
|
10792
|
+
});
|
|
10793
|
+
}
|
|
10794
|
+
ensureLocalAutomation(session, owner) {
|
|
10795
|
+
const current = session.localAutomation;
|
|
10796
|
+
if (current?.generation === owner.generation && current.browser.isConnected()) return Promise.resolve(current);
|
|
10797
|
+
if (session.localAutomationLaunch) return session.localAutomationLaunch;
|
|
10798
|
+
if (current) {
|
|
10799
|
+
session.localAutomation = null;
|
|
10800
|
+
current.frameProducer.stop();
|
|
10801
|
+
}
|
|
10802
|
+
const launch = (async () => {
|
|
10803
|
+
if (current) {
|
|
10804
|
+
await current.cdp.send("Page.stopScreencast").catch(() => void 0);
|
|
10805
|
+
await current.cdp.detach().catch(() => void 0);
|
|
10806
|
+
await current.browser.close().catch(() => void 0);
|
|
10807
|
+
}
|
|
10808
|
+
if (!owner.ready) {
|
|
10809
|
+
let readinessTimer = null;
|
|
10810
|
+
await Promise.race([owner.readyPromise, new Promise((_resolve, reject) => {
|
|
10811
|
+
readinessTimer = setTimeout(() => reject(/* @__PURE__ */ new Error("The visible local Browser did not become ready within 15 seconds.")), 15e3);
|
|
10812
|
+
readinessTimer.unref();
|
|
10813
|
+
})]).finally(() => {
|
|
10814
|
+
if (readinessTimer) clearTimeout(readinessTimer);
|
|
10815
|
+
});
|
|
10816
|
+
}
|
|
10817
|
+
if (!owner.ready || session.localOwner !== owner || session.generation !== owner.generation) throw new Error("The local Browser owner changed before it was ready.");
|
|
10818
|
+
const browser = await this.connectLocalAutomation(owner.endpoint);
|
|
10819
|
+
const context = browser.contexts()[0];
|
|
10820
|
+
const page = context?.pages()[0];
|
|
10821
|
+
if (!context || !page) {
|
|
10822
|
+
await browser.close().catch(() => void 0);
|
|
10823
|
+
throw new Error("The local Browser page is not available.");
|
|
10824
|
+
}
|
|
10825
|
+
if (session.localOwner !== owner || session.generation !== owner.generation) {
|
|
10826
|
+
await browser.close().catch(() => void 0);
|
|
10827
|
+
throw new Error("The local Browser owner changed.");
|
|
10828
|
+
}
|
|
10829
|
+
const cdp = await context.newCDPSession(page);
|
|
10830
|
+
const frameProducer = new LatestBrowserFrameProducer((frame) => {
|
|
10831
|
+
if (session.localOwner === owner && session.localAutomation?.generation === owner.generation) this.publishFrame(session, frame);
|
|
10832
|
+
}, (sessionId) => {
|
|
10833
|
+
cdp.send("Page.screencastFrameAck", { sessionId }).catch(() => void 0);
|
|
10834
|
+
});
|
|
10835
|
+
const automation = {
|
|
10836
|
+
browser,
|
|
10837
|
+
page,
|
|
10838
|
+
cdp,
|
|
10839
|
+
frameProducer,
|
|
10840
|
+
generation: owner.generation,
|
|
10841
|
+
console: [],
|
|
10842
|
+
requests: [],
|
|
10843
|
+
screencasting: false,
|
|
10844
|
+
screencastTail: Promise.resolve()
|
|
10845
|
+
};
|
|
10846
|
+
cdp.on("Page.screencastFrame", (frame) => frameProducer.receive(frame));
|
|
10847
|
+
browser.once("disconnected", () => {
|
|
10848
|
+
if (session.localAutomation !== automation) return;
|
|
10849
|
+
automation.frameProducer.stop();
|
|
10850
|
+
automation.screencasting = false;
|
|
10851
|
+
session.localAutomation = null;
|
|
10852
|
+
this.scheduleOperation(session, () => this.updateScreencast(session), { required: true }).catch((cause) => this.broadcastNavigationError(session, cause instanceof Error ? cause.message : String(cause)));
|
|
10853
|
+
});
|
|
10854
|
+
await cdp.send("Page.enable");
|
|
10855
|
+
page.on("console", (message) => {
|
|
10856
|
+
automation.console.push(`${message.type()}: ${message.text()}`);
|
|
10857
|
+
if (automation.console.length > 1e3) automation.console.shift();
|
|
10858
|
+
});
|
|
10859
|
+
page.on("pageerror", (error) => {
|
|
10860
|
+
automation.console.push(`error: ${error.message}`);
|
|
10861
|
+
if (automation.console.length > 1e3) automation.console.shift();
|
|
10862
|
+
});
|
|
10863
|
+
page.on("request", (request) => {
|
|
10864
|
+
automation.requests.push(`${request.method()} ${request.url()}`);
|
|
10865
|
+
if (automation.requests.length > 2e3) automation.requests.shift();
|
|
10866
|
+
});
|
|
10867
|
+
session.localAutomation = automation;
|
|
10868
|
+
return automation;
|
|
10869
|
+
})();
|
|
10870
|
+
session.localAutomationLaunch = launch;
|
|
10871
|
+
const clearLaunch = () => {
|
|
10872
|
+
if (session.localAutomationLaunch === launch) session.localAutomationLaunch = null;
|
|
10873
|
+
};
|
|
10874
|
+
launch.then(clearLaunch, clearLaunch);
|
|
10875
|
+
return launch;
|
|
10876
|
+
}
|
|
10877
|
+
async closeLocalAutomation(session) {
|
|
10878
|
+
const launch = session.localAutomationLaunch;
|
|
10879
|
+
const automation = session.localAutomation ?? await launch?.catch(() => null);
|
|
10880
|
+
session.localAutomation = null;
|
|
10881
|
+
session.localAutomationLaunch = null;
|
|
10882
|
+
if (automation) {
|
|
10883
|
+
automation.frameProducer.stop();
|
|
10884
|
+
await automation.cdp.send("Page.stopScreencast").catch(() => void 0);
|
|
10885
|
+
await automation.cdp.detach().catch(() => void 0);
|
|
10886
|
+
await automation.browser.close().catch(() => void 0);
|
|
10887
|
+
}
|
|
10888
|
+
}
|
|
10889
|
+
async setLocalScreencasting(session, owner, enabled) {
|
|
10890
|
+
const automation = enabled ? await this.ensureLocalAutomation(session, owner) : session.localAutomation;
|
|
10891
|
+
if (!automation || automation.generation !== owner.generation) return;
|
|
10892
|
+
const operation = automation.screencastTail.then(async () => {
|
|
10893
|
+
if (session.localOwner !== owner || session.localAutomation !== automation || automation.screencasting === enabled) return;
|
|
10894
|
+
automation.screencasting = enabled;
|
|
10895
|
+
if (!enabled) {
|
|
10896
|
+
automation.frameProducer.stop();
|
|
10897
|
+
await automation.cdp.send("Page.stopScreencast").catch(() => void 0);
|
|
10898
|
+
return;
|
|
10899
|
+
}
|
|
10900
|
+
automation.frameProducer.start();
|
|
10901
|
+
const width = Math.max(320, session.state.viewport.width || 1280);
|
|
10902
|
+
const height = Math.max(200, session.state.viewport.height || 800);
|
|
10903
|
+
await automation.cdp.send("Page.startScreencast", {
|
|
10904
|
+
format: "jpeg",
|
|
10905
|
+
quality: 75,
|
|
10906
|
+
maxWidth: width,
|
|
10907
|
+
maxHeight: height,
|
|
10908
|
+
everyNthFrame: 1
|
|
10909
|
+
}).catch((cause) => {
|
|
10910
|
+
automation.screencasting = false;
|
|
10911
|
+
automation.frameProducer.stop();
|
|
10912
|
+
throw cause;
|
|
10913
|
+
});
|
|
10914
|
+
});
|
|
10915
|
+
automation.screencastTail = operation.catch(() => void 0);
|
|
10916
|
+
await operation;
|
|
10917
|
+
}
|
|
10918
|
+
async executeLocalClientCommand(session, owner, message) {
|
|
10919
|
+
const automation = await this.ensureLocalAutomation(session, owner);
|
|
10920
|
+
const page = automation.page;
|
|
10921
|
+
if (message.type === "navigate") await page.goto(message.url, { waitUntil: "commit" });
|
|
10922
|
+
else if (message.type === "back") await page.goBack({ waitUntil: "commit" });
|
|
10923
|
+
else if (message.type === "forward") await page.goForward({ waitUntil: "commit" });
|
|
10924
|
+
else if (message.type === "reload") await page.reload({ waitUntil: "commit" });
|
|
10925
|
+
else if (message.type === "stop") await automation.cdp.send("Page.stopLoading");
|
|
10926
|
+
else if (message.type === "pointer") {
|
|
10927
|
+
await page.mouse.move(message.x, message.y);
|
|
10928
|
+
if (message.phase === "down") await page.mouse.down({ button: message.button ?? "left" });
|
|
10929
|
+
else if (message.phase === "up") await page.mouse.up({ button: message.button ?? "left" });
|
|
10930
|
+
} else if (message.type === "wheel") await page.mouse.wheel(message.deltaX, message.deltaY);
|
|
10931
|
+
else if (message.type === "key") if (message.phase === "down") await page.keyboard.down(message.key);
|
|
10932
|
+
else await page.keyboard.up(message.key);
|
|
10933
|
+
else if (message.type === "insertText") await page.keyboard.insertText(message.text);
|
|
10934
|
+
}
|
|
10935
|
+
async executeLocalAgentCommand(session, owner, input) {
|
|
10936
|
+
const automation = await this.ensureLocalAutomation(session, owner);
|
|
10937
|
+
const page = automation.page;
|
|
10938
|
+
const target = (value) => page.locator(`aria-ref=${value}`);
|
|
10939
|
+
if (input.command === "snapshot") return Reflect.apply(page.ariaSnapshot, page, [{ mode: "ai" }]);
|
|
10940
|
+
if (input.command === "click") {
|
|
10941
|
+
await target(input.args[0]).click();
|
|
10942
|
+
return `Clicked ${input.args[0]}`;
|
|
10943
|
+
}
|
|
10944
|
+
if (input.command === "fill") {
|
|
10945
|
+
await target(input.args[0]).fill(input.args[1]);
|
|
10946
|
+
return `Filled ${input.args[0]}`;
|
|
10947
|
+
}
|
|
10948
|
+
if (input.command === "press") {
|
|
10949
|
+
await page.keyboard.press(input.args[0]);
|
|
10950
|
+
return `Pressed ${input.args[0]}`;
|
|
10951
|
+
}
|
|
10952
|
+
if (input.command === "console") {
|
|
10953
|
+
const minimum = input.args[0] ?? "info";
|
|
10954
|
+
const levels = [
|
|
10955
|
+
"debug",
|
|
10956
|
+
"info",
|
|
10957
|
+
"warning",
|
|
10958
|
+
"error"
|
|
10959
|
+
];
|
|
10960
|
+
const minimumIndex = Math.max(0, levels.indexOf(minimum));
|
|
10961
|
+
return automation.console.filter((line) => {
|
|
10962
|
+
const level = line.slice(0, line.indexOf(":"));
|
|
10963
|
+
const index = levels.indexOf(level);
|
|
10964
|
+
return index < 0 || index >= minimumIndex;
|
|
10965
|
+
}).join("\n") || "No console messages.";
|
|
10966
|
+
}
|
|
10967
|
+
if (input.command === "requests") return automation.requests.join("\n") || "No network requests.";
|
|
10968
|
+
if (input.command === "screenshot") {
|
|
10969
|
+
const screenshotPath = path.join(session.agentDirectory, `screenshot-${Date.now()}.png`);
|
|
10970
|
+
await page.screenshot({ path: screenshotPath });
|
|
10971
|
+
return `Screenshot saved to ${screenshotPath}`;
|
|
10972
|
+
}
|
|
10973
|
+
if (input.command === "goto") {
|
|
10974
|
+
await page.goto(input.args[0]);
|
|
10975
|
+
return `Navigated to ${page.url()}`;
|
|
10976
|
+
}
|
|
10977
|
+
if (input.command === "go-back") {
|
|
10978
|
+
await page.goBack();
|
|
10979
|
+
return `Navigated to ${page.url()}`;
|
|
10980
|
+
}
|
|
10981
|
+
if (input.command === "go-forward") {
|
|
10982
|
+
await page.goForward();
|
|
10983
|
+
return `Navigated to ${page.url()}`;
|
|
10984
|
+
}
|
|
10985
|
+
await page.reload();
|
|
10986
|
+
return `Reloaded ${page.url()}`;
|
|
10987
|
+
}
|
|
10988
|
+
async agentCommand(panelId, input) {
|
|
10989
|
+
const session = await this.getSession(panelId);
|
|
10990
|
+
let result = "";
|
|
10991
|
+
await this.scheduleOperation(session, async () => {
|
|
10992
|
+
const localOwner = session.localOwner;
|
|
10993
|
+
const browser = localOwner ? null : await this.browserFor(session).catch(async (error) => {
|
|
10994
|
+
await this.closePlaywrightRuntime(session).catch(() => void 0);
|
|
10995
|
+
throw error;
|
|
10996
|
+
});
|
|
10997
|
+
const previousController = session.controllerId;
|
|
10998
|
+
let agentControlled = false;
|
|
10999
|
+
try {
|
|
11000
|
+
if (localOwner) {
|
|
11001
|
+
await this.updateScreencast(session, "agent");
|
|
11002
|
+
if (session.localOwner !== localOwner || localOwner.generation !== session.generation) throw new Error("The local Browser owner changed.");
|
|
11003
|
+
}
|
|
11004
|
+
session.controllerId = "agent";
|
|
11005
|
+
agentControlled = true;
|
|
11006
|
+
this.broadcastState(session, "controlChanged");
|
|
11007
|
+
if (localOwner) result = await this.executeLocalAgentCommand(session, localOwner, input);
|
|
11008
|
+
else if (this.agentCliRunner) {
|
|
11009
|
+
const name = session.agentSessionName ?? `treeport-${panelId}-${session.generation}-${crypto.randomBytes(6).toString("hex")}`;
|
|
11010
|
+
session.agentSessionName = name;
|
|
11011
|
+
if (!session.agentAttached) {
|
|
11012
|
+
await this.executeAgentCli(session, [
|
|
11013
|
+
"attach",
|
|
11014
|
+
`treeport-${panelId}`,
|
|
11015
|
+
"--session",
|
|
11016
|
+
name
|
|
11017
|
+
]);
|
|
11018
|
+
session.agentAttached = true;
|
|
11019
|
+
}
|
|
11020
|
+
result = await this.executeAgentCli(session, [
|
|
11021
|
+
`-s=${name}`,
|
|
11022
|
+
input.command,
|
|
11023
|
+
"--",
|
|
11024
|
+
...input.args
|
|
11025
|
+
]);
|
|
11026
|
+
this.queuePanelState(session, browser.state);
|
|
11027
|
+
} else {
|
|
11028
|
+
result = await browser.agentCommand(input);
|
|
11029
|
+
this.queuePanelState(session, browser.state);
|
|
11030
|
+
}
|
|
11031
|
+
await this.waitForPanelState(session);
|
|
11032
|
+
} catch (cause) {
|
|
11033
|
+
if (!localOwner) session.agentAttached = false;
|
|
11034
|
+
throw cause;
|
|
11035
|
+
} finally {
|
|
11036
|
+
if (agentControlled) {
|
|
11037
|
+
if (localOwner && session.localOwner === localOwner) {
|
|
11038
|
+
const nextController = previousController && previousController !== "agent" ? previousController : LOCAL_BROWSER_OWNER_CONTROLLER;
|
|
11039
|
+
if (await this.updateScreencast(session, nextController).then(() => true, () => false)) session.controllerId = nextController;
|
|
11040
|
+
} else {
|
|
11041
|
+
const nextAttachment = [...session.attachments.values()].find((attachment) => !attachment.closing && attachment.visible);
|
|
11042
|
+
session.controllerId = previousController && previousController !== "agent" ? previousController : nextAttachment ? attachmentController(nextAttachment.clientId) : null;
|
|
11043
|
+
}
|
|
11044
|
+
this.broadcastState(session, "controlChanged");
|
|
11045
|
+
}
|
|
8456
11046
|
}
|
|
11047
|
+
});
|
|
11048
|
+
return result;
|
|
11049
|
+
}
|
|
11050
|
+
async status() {
|
|
11051
|
+
return PlaywrightBrowser.status(this.cachePath);
|
|
11052
|
+
}
|
|
11053
|
+
async install() {
|
|
11054
|
+
if (this.installing) return this.installing;
|
|
11055
|
+
this.installing = (async () => {
|
|
11056
|
+
const packageJsonPath = createRequire(import.meta.url).resolve("playwright/package.json");
|
|
11057
|
+
const packageJson = playwrightPackageSchema.parse(JSON.parse(await fs.readFile(packageJsonPath, "utf8")));
|
|
11058
|
+
const cli = path.join(path.dirname(packageJsonPath), packageJson.bin?.playwright ?? "cli.js");
|
|
11059
|
+
return new Promise((resolve, reject) => {
|
|
11060
|
+
const child = spawn(process.execPath, [
|
|
11061
|
+
cli,
|
|
11062
|
+
"install",
|
|
11063
|
+
"chromium",
|
|
11064
|
+
"--no-shell"
|
|
11065
|
+
], {
|
|
11066
|
+
env: {
|
|
11067
|
+
...process.env,
|
|
11068
|
+
PLAYWRIGHT_BROWSERS_PATH: this.cachePath
|
|
11069
|
+
},
|
|
11070
|
+
stdio: [
|
|
11071
|
+
"ignore",
|
|
11072
|
+
"pipe",
|
|
11073
|
+
"pipe"
|
|
11074
|
+
]
|
|
11075
|
+
});
|
|
11076
|
+
let output = "";
|
|
11077
|
+
child.stdout.on("data", (data) => output += String(data));
|
|
11078
|
+
child.stderr.on("data", (data) => output += String(data));
|
|
11079
|
+
child.once("error", reject);
|
|
11080
|
+
child.once("exit", (code) => {
|
|
11081
|
+
if (code === 0) resolve(output.trim() || "Chromium installed.");
|
|
11082
|
+
else reject(new Error(output.trim() || `Playwright exited with ${code}`));
|
|
11083
|
+
});
|
|
11084
|
+
});
|
|
11085
|
+
})();
|
|
11086
|
+
try {
|
|
11087
|
+
return await this.installing;
|
|
11088
|
+
} finally {
|
|
11089
|
+
this.installing = null;
|
|
8457
11090
|
}
|
|
8458
|
-
}
|
|
8459
|
-
|
|
11091
|
+
}
|
|
11092
|
+
async remove() {
|
|
11093
|
+
if (this.installing) throw new Error("Wait for the Browser installation to finish.");
|
|
11094
|
+
if (this.browserHost.started || [...this.sessions.values()].some((session) => session.browser !== null || session.launch !== null)) throw new Error("Close daemon-owned Browser before you remove Chromium.");
|
|
11095
|
+
await fs.rm(this.cachePath, {
|
|
11096
|
+
recursive: true,
|
|
11097
|
+
force: true
|
|
11098
|
+
});
|
|
11099
|
+
}
|
|
11100
|
+
async dispose() {
|
|
11101
|
+
this.unsubscribe();
|
|
11102
|
+
this.tickets.clear();
|
|
11103
|
+
this.ownerTickets.clear();
|
|
11104
|
+
await Promise.all([...this.sessionCreations.values()].map((creation) => creation.catch(() => null)));
|
|
11105
|
+
await Promise.all([...this.sessions.keys()].map((panelId) => this.closePanel(panelId, "Treeport is shutting down.")));
|
|
11106
|
+
await this.browserHost.close();
|
|
11107
|
+
}
|
|
11108
|
+
};
|
|
8460
11109
|
//#endregion
|
|
8461
11110
|
//#region src/server/daemon-ownership.ts
|
|
8462
11111
|
function processExists(pid) {
|
|
@@ -8517,167 +11166,6 @@ async function acquireDaemonOwnership(config) {
|
|
|
8517
11166
|
};
|
|
8518
11167
|
}
|
|
8519
11168
|
//#endregion
|
|
8520
|
-
//#region src/server/request-security.ts
|
|
8521
|
-
const MAX_HOST_BYTES = 512;
|
|
8522
|
-
const MAX_LOGIN_BYTES = 320;
|
|
8523
|
-
const MAX_NAME_BYTES = 512;
|
|
8524
|
-
const MAX_PROFILE_PICTURE_BYTES = 2048;
|
|
8525
|
-
const UNSAFE_METHODS = /* @__PURE__ */ new Set([
|
|
8526
|
-
"POST",
|
|
8527
|
-
"PUT",
|
|
8528
|
-
"PATCH",
|
|
8529
|
-
"DELETE"
|
|
8530
|
-
]);
|
|
8531
|
-
function denied(status, code, message) {
|
|
8532
|
-
return {
|
|
8533
|
-
allowed: false,
|
|
8534
|
-
principal: null,
|
|
8535
|
-
effectiveOrigin: null,
|
|
8536
|
-
status,
|
|
8537
|
-
code,
|
|
8538
|
-
message
|
|
8539
|
-
};
|
|
8540
|
-
}
|
|
8541
|
-
function headerValues(request, name) {
|
|
8542
|
-
const values = [];
|
|
8543
|
-
for (let index = 0; index < request.rawHeaders.length; index += 2) if (request.rawHeaders[index]?.toLowerCase() === name) values.push(request.rawHeaders[index + 1] ?? "");
|
|
8544
|
-
if (values.length > 0) return values;
|
|
8545
|
-
const value = request.headers[name];
|
|
8546
|
-
if (value === void 0) return [];
|
|
8547
|
-
return Array.isArray(value) ? value : [value];
|
|
8548
|
-
}
|
|
8549
|
-
function singleHeader(request, name) {
|
|
8550
|
-
const values = headerValues(request, name);
|
|
8551
|
-
return {
|
|
8552
|
-
present: values.length > 0,
|
|
8553
|
-
valid: values.length <= 1,
|
|
8554
|
-
value: values.length === 1 ? values[0] ?? null : null
|
|
8555
|
-
};
|
|
8556
|
-
}
|
|
8557
|
-
function hasControlCharacters(value) {
|
|
8558
|
-
return [...value].some((character) => {
|
|
8559
|
-
const code = character.codePointAt(0) ?? 0;
|
|
8560
|
-
return code <= 31 || code === 127;
|
|
8561
|
-
});
|
|
8562
|
-
}
|
|
8563
|
-
function parseHost(value) {
|
|
8564
|
-
if (value.length === 0 || Buffer.byteLength(value) > MAX_HOST_BYTES || hasControlCharacters(value) || /[\s,/@?#\\]/u.test(value)) return null;
|
|
8565
|
-
const candidate = `http://${value}`;
|
|
8566
|
-
if (!URL.canParse(candidate)) return null;
|
|
8567
|
-
const url = new URL(candidate);
|
|
8568
|
-
if (!url.host || url.pathname !== "/") return null;
|
|
8569
|
-
return {
|
|
8570
|
-
host: url.host,
|
|
8571
|
-
hostname: url.hostname.toLowerCase()
|
|
8572
|
-
};
|
|
8573
|
-
}
|
|
8574
|
-
function isLoopbackHostname(hostname) {
|
|
8575
|
-
return [
|
|
8576
|
-
"127.0.0.1",
|
|
8577
|
-
"localhost",
|
|
8578
|
-
"[::1]",
|
|
8579
|
-
"::1"
|
|
8580
|
-
].includes(hostname);
|
|
8581
|
-
}
|
|
8582
|
-
function isLoopbackAddress(address) {
|
|
8583
|
-
if (!address) return false;
|
|
8584
|
-
const normalized = address.toLowerCase();
|
|
8585
|
-
if (normalized === "::1") return true;
|
|
8586
|
-
const octets = (normalized.startsWith("::ffff:") ? normalized.slice(7) : normalized).split(".");
|
|
8587
|
-
return octets.length === 4 && octets[0] === "127" && octets.every((octet) => /^\d{1,3}$/u.test(octet) && Number(octet) <= 255);
|
|
8588
|
-
}
|
|
8589
|
-
function validIdentityValue(value, maximumBytes) {
|
|
8590
|
-
return value.length > 0 && value === value.trim() && Buffer.byteLength(value) <= maximumBytes && !hasControlCharacters(value);
|
|
8591
|
-
}
|
|
8592
|
-
function effectiveOriginFor(request, source, incomingHost) {
|
|
8593
|
-
if (source === "local") return `http://${incomingHost.host}`;
|
|
8594
|
-
const forwardedHostHeader = singleHeader(request, "x-forwarded-host");
|
|
8595
|
-
const forwardedProtocolHeader = singleHeader(request, "x-forwarded-proto");
|
|
8596
|
-
if (!forwardedHostHeader.valid || !forwardedHostHeader.present || !forwardedProtocolHeader.valid || !forwardedProtocolHeader.present) return null;
|
|
8597
|
-
const forwardedHost = parseHost(forwardedHostHeader.value ?? "");
|
|
8598
|
-
if (!forwardedHost || forwardedProtocolHeader.value?.toLowerCase() !== "https") return null;
|
|
8599
|
-
return `https://${forwardedHost.host}`;
|
|
8600
|
-
}
|
|
8601
|
-
function allowsOpaqueWebPanelOrigin(request, socketUpgrade) {
|
|
8602
|
-
if (!["GET", "HEAD"].includes(request.method?.toUpperCase() ?? "")) return false;
|
|
8603
|
-
const pathname = new URL(request.url ?? "/", "http://treeport.local").pathname;
|
|
8604
|
-
if (socketUpgrade) return /^\/api\/web-panel-dev\/[a-f0-9]{24}\/@vite-hmr$/u.test(pathname);
|
|
8605
|
-
return /^\/api\/web-panels\/panel_[a-f0-9]{32}\/assets(?:\/|$)/u.test(pathname) || /^\/api\/web-panel-dev\/[a-f0-9]{24}\//u.test(pathname);
|
|
8606
|
-
}
|
|
8607
|
-
function originIsAllowed(request, effectiveOrigin, socketUpgrade) {
|
|
8608
|
-
const originHeader = singleHeader(request, "origin");
|
|
8609
|
-
if (!originHeader.valid) return false;
|
|
8610
|
-
if (originHeader.present) {
|
|
8611
|
-
const value = originHeader.value ?? "";
|
|
8612
|
-
if (value === "null") {
|
|
8613
|
-
if (!allowsOpaqueWebPanelOrigin(request, socketUpgrade)) return false;
|
|
8614
|
-
} else {
|
|
8615
|
-
if (!URL.canParse(value)) return false;
|
|
8616
|
-
const parsed = new URL(value);
|
|
8617
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.origin !== value || parsed.origin !== effectiveOrigin) return false;
|
|
8618
|
-
}
|
|
8619
|
-
}
|
|
8620
|
-
const fetchSiteHeader = singleHeader(request, "sec-fetch-site");
|
|
8621
|
-
if (!fetchSiteHeader.valid) return false;
|
|
8622
|
-
if ((UNSAFE_METHODS.has(request.method?.toUpperCase() ?? "") || socketUpgrade) && fetchSiteHeader.value?.toLowerCase() === "cross-site") return false;
|
|
8623
|
-
return true;
|
|
8624
|
-
}
|
|
8625
|
-
function authorizeRequest(request, options = {}) {
|
|
8626
|
-
if (!isLoopbackAddress(request.socket.remoteAddress)) return denied(401, "AUTHENTICATION_REQUIRED", "Treeport accepts remote requests only through Tailscale Serve.");
|
|
8627
|
-
const hostHeader = singleHeader(request, "host");
|
|
8628
|
-
const incomingHost = hostHeader.valid && hostHeader.value ? parseHost(hostHeader.value.toLowerCase()) : null;
|
|
8629
|
-
if (!incomingHost) return denied(400, "INVALID_REQUEST_HOST", "The request host is invalid.");
|
|
8630
|
-
const loginHeader = singleHeader(request, "tailscale-user-login");
|
|
8631
|
-
const nameHeader = singleHeader(request, "tailscale-user-name");
|
|
8632
|
-
const profilePictureHeader = singleHeader(request, "tailscale-user-profile-pic");
|
|
8633
|
-
const hasTailscaleIdentity = loginHeader.present || nameHeader.present || profilePictureHeader.present;
|
|
8634
|
-
const headersHaveSingleValues = loginHeader.valid && nameHeader.valid && profilePictureHeader.valid;
|
|
8635
|
-
let principal;
|
|
8636
|
-
if (hasTailscaleIdentity) {
|
|
8637
|
-
const login = loginHeader.value ?? "";
|
|
8638
|
-
const name = nameHeader.value === "" ? null : nameHeader.value;
|
|
8639
|
-
const profilePicture = profilePictureHeader.value === "" ? null : profilePictureHeader.value;
|
|
8640
|
-
const validProfilePicture = profilePicture === null || validIdentityValue(profilePicture, MAX_PROFILE_PICTURE_BYTES) && URL.canParse(profilePicture) && ["http:", "https:"].includes(new URL(profilePicture).protocol);
|
|
8641
|
-
if (!headersHaveSingleValues || !loginHeader.present || !validIdentityValue(login, MAX_LOGIN_BYTES) || name !== null && !validIdentityValue(name, MAX_NAME_BYTES) || !validProfilePicture) return denied(401, "INVALID_TAILSCALE_IDENTITY", "The Tailscale user identity is invalid.");
|
|
8642
|
-
principal = {
|
|
8643
|
-
source: "tailscale",
|
|
8644
|
-
login,
|
|
8645
|
-
name,
|
|
8646
|
-
profilePicture
|
|
8647
|
-
};
|
|
8648
|
-
} else {
|
|
8649
|
-
if (!isLoopbackHostname(incomingHost.hostname)) return denied(401, "AUTHENTICATION_REQUIRED", "Treeport accepts remote requests only through Tailscale Serve.");
|
|
8650
|
-
principal = {
|
|
8651
|
-
source: "local",
|
|
8652
|
-
login: null,
|
|
8653
|
-
name: null,
|
|
8654
|
-
profilePicture: null
|
|
8655
|
-
};
|
|
8656
|
-
}
|
|
8657
|
-
const effectiveOrigin = effectiveOriginFor(request, principal.source, incomingHost);
|
|
8658
|
-
if (!effectiveOrigin) return denied(400, "INVALID_FORWARDED_ORIGIN", "The forwarded request origin is invalid.");
|
|
8659
|
-
if (!originIsAllowed(request, effectiveOrigin, options.socketUpgrade === true)) return denied(403, "INVALID_ORIGIN", "The request origin is not allowed.");
|
|
8660
|
-
return {
|
|
8661
|
-
allowed: true,
|
|
8662
|
-
principal,
|
|
8663
|
-
effectiveOrigin,
|
|
8664
|
-
status: 200,
|
|
8665
|
-
code: null,
|
|
8666
|
-
message: null
|
|
8667
|
-
};
|
|
8668
|
-
}
|
|
8669
|
-
function rejectHttpRequest(request, response, decision) {
|
|
8670
|
-
const apiRequest = request.url?.startsWith("/api/") === true;
|
|
8671
|
-
const body = apiRequest ? `${JSON.stringify({ error: {
|
|
8672
|
-
code: decision.code,
|
|
8673
|
-
message: decision.message
|
|
8674
|
-
} })}\n` : `${decision.message ?? "The request was rejected."}\n`;
|
|
8675
|
-
response.statusCode = decision.status;
|
|
8676
|
-
response.setHeader("cache-control", "no-store");
|
|
8677
|
-
response.setHeader("content-type", apiRequest ? "application/json; charset=utf-8" : "text/plain; charset=utf-8");
|
|
8678
|
-
response.end(request.method === "HEAD" ? void 0 : body);
|
|
8679
|
-
}
|
|
8680
|
-
//#endregion
|
|
8681
11169
|
//#region src/server/terminal-attachments.ts
|
|
8682
11170
|
var AttachmentInitializationError = class {
|
|
8683
11171
|
phase;
|
|
@@ -9290,7 +11778,7 @@ var TerminalAttachmentManager = class {
|
|
|
9290
11778
|
};
|
|
9291
11779
|
//#endregion
|
|
9292
11780
|
//#region src/server/socket-server.ts
|
|
9293
|
-
function createSocketServer(httpServer, { service, config, tmux, terminalMetadata, attachmentManager }) {
|
|
11781
|
+
function createSocketServer(httpServer, { service, config, tmux, terminalMetadata, attachmentManager, browserSessions }) {
|
|
9294
11782
|
const io = new Server(httpServer, {
|
|
9295
11783
|
path: SOCKET_IO_PATH,
|
|
9296
11784
|
serveClient: false,
|
|
@@ -9301,6 +11789,7 @@ function createSocketServer(httpServer, { service, config, tmux, terminalMetadat
|
|
|
9301
11789
|
});
|
|
9302
11790
|
const metadataReady = terminalMetadata.initialize();
|
|
9303
11791
|
const attachments = attachmentManager ?? new TerminalAttachmentManager(service, tmux, config.tmuxPath, terminalMetadata);
|
|
11792
|
+
const hostedBrowsers = browserSessions ?? new BrowserSessionManager(service, config);
|
|
9304
11793
|
io.of("/events").on("connection", (socket) => {
|
|
9305
11794
|
const queuedEvents = [];
|
|
9306
11795
|
let snapshotted = false;
|
|
@@ -9313,12 +11802,13 @@ function createSocketServer(httpServer, { service, config, tmux, terminalMetadat
|
|
|
9313
11802
|
if (!socket.connected) return;
|
|
9314
11803
|
const metadataSnapshot = terminalMetadata.snapshot();
|
|
9315
11804
|
const representedEventCount = queuedEvents.length;
|
|
9316
|
-
service.listWebPanels().then((webPanels) => {
|
|
11805
|
+
Promise.all([service.listWebPanels(), service.listBrowserPanels()]).then(([webPanels, browserPanels]) => {
|
|
9317
11806
|
if (!socket.connected) return;
|
|
9318
11807
|
socket.emit("snapshot", {
|
|
9319
11808
|
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
9320
11809
|
terminalMetadata: metadataSnapshot,
|
|
9321
|
-
webPanels
|
|
11810
|
+
webPanels,
|
|
11811
|
+
browserPanels
|
|
9322
11812
|
});
|
|
9323
11813
|
queuedEvents.splice(0, representedEventCount);
|
|
9324
11814
|
while (queuedEvents.length && socket.connected) socket.emit("product_event", queuedEvents.shift());
|
|
@@ -9371,9 +11861,69 @@ function createSocketServer(httpServer, { service, config, tmux, terminalMetadat
|
|
|
9371
11861
|
socket.on("output_ack", (payload) => attachments.message(connectionId, "output_ack", payload));
|
|
9372
11862
|
socket.once("disconnect", () => attachments.close(connectionId));
|
|
9373
11863
|
});
|
|
11864
|
+
const browsers = io.of("/browsers");
|
|
11865
|
+
browsers.use((socket, next) => {
|
|
11866
|
+
const auth = parseBrowserAuth(socket.handshake.auth);
|
|
11867
|
+
if (!auth) {
|
|
11868
|
+
next(/* @__PURE__ */ new Error("INVALID_BROWSER_AUTH"));
|
|
11869
|
+
return;
|
|
11870
|
+
}
|
|
11871
|
+
socket.data.browserTicket = auth.ticket;
|
|
11872
|
+
next();
|
|
11873
|
+
});
|
|
11874
|
+
browsers.on("connection", (socket) => {
|
|
11875
|
+
const transport = {
|
|
11876
|
+
id: socket.id,
|
|
11877
|
+
isConnected: () => socket.connected,
|
|
11878
|
+
sendMessage(message) {
|
|
11879
|
+
if (!socket.connected) return false;
|
|
11880
|
+
socket.emit("message", message);
|
|
11881
|
+
return true;
|
|
11882
|
+
},
|
|
11883
|
+
sendFrame(frame) {
|
|
11884
|
+
if (!socket.connected) return false;
|
|
11885
|
+
socket.emit("frame", frame);
|
|
11886
|
+
return true;
|
|
11887
|
+
},
|
|
11888
|
+
disconnect() {
|
|
11889
|
+
socket.disconnect(true);
|
|
11890
|
+
}
|
|
11891
|
+
};
|
|
11892
|
+
socket.on("command", (message) => hostedBrowsers.message(socket.id, message));
|
|
11893
|
+
socket.once("disconnect", () => hostedBrowsers.close(socket.id));
|
|
11894
|
+
hostedBrowsers.accept(socket.data.browserTicket, transport).catch(() => socket.disconnect(true));
|
|
11895
|
+
});
|
|
11896
|
+
const browserOwners = io.of("/browser-owners");
|
|
11897
|
+
browserOwners.use((socket, next) => {
|
|
11898
|
+
const auth = parseBrowserOwnerAuth(socket.handshake.auth);
|
|
11899
|
+
if (!auth) {
|
|
11900
|
+
next(/* @__PURE__ */ new Error("INVALID_BROWSER_OWNER_AUTH"));
|
|
11901
|
+
return;
|
|
11902
|
+
}
|
|
11903
|
+
socket.data.browserOwnerAuth = auth;
|
|
11904
|
+
next();
|
|
11905
|
+
});
|
|
11906
|
+
browserOwners.on("connection", (socket) => {
|
|
11907
|
+
const transport = {
|
|
11908
|
+
id: socket.id,
|
|
11909
|
+
isConnected: () => socket.connected,
|
|
11910
|
+
send(message) {
|
|
11911
|
+
if (!socket.connected) return false;
|
|
11912
|
+
socket.emit("ownerMessage", message);
|
|
11913
|
+
return true;
|
|
11914
|
+
},
|
|
11915
|
+
disconnect() {
|
|
11916
|
+
socket.disconnect(true);
|
|
11917
|
+
}
|
|
11918
|
+
};
|
|
11919
|
+
socket.on("ownerMessage", (message) => hostedBrowsers.ownerMessage(socket.id, message));
|
|
11920
|
+
socket.once("disconnect", () => hostedBrowsers.closeOwner(socket.id));
|
|
11921
|
+
hostedBrowsers.acceptOwner(socket.data.browserOwnerAuth, transport).catch(() => socket.disconnect(true));
|
|
11922
|
+
});
|
|
9374
11923
|
return {
|
|
9375
11924
|
io,
|
|
9376
|
-
attachments
|
|
11925
|
+
attachments,
|
|
11926
|
+
browserSessions: hostedBrowsers
|
|
9377
11927
|
};
|
|
9378
11928
|
}
|
|
9379
11929
|
//#endregion
|
|
@@ -9406,12 +11956,14 @@ async function main() {
|
|
|
9406
11956
|
const terminalMetadata = new TerminalMetadataManager(service, tmux, config.tmuxPath);
|
|
9407
11957
|
await terminalMetadata.initialize();
|
|
9408
11958
|
const applicationUpdate = createApplicationUpdateManager(config);
|
|
11959
|
+
const browserSessions = new BrowserSessionManager(service, config);
|
|
9409
11960
|
const honoListener = getRequestListener(createApp({
|
|
9410
11961
|
service,
|
|
9411
11962
|
config,
|
|
9412
11963
|
tmux,
|
|
9413
11964
|
applicationUpdate,
|
|
9414
|
-
terminalMetadata
|
|
11965
|
+
terminalMetadata,
|
|
11966
|
+
browserSessions
|
|
9415
11967
|
}).fetch);
|
|
9416
11968
|
let vite = null;
|
|
9417
11969
|
const server = createServer((request, response) => {
|
|
@@ -9453,7 +12005,8 @@ async function main() {
|
|
|
9453
12005
|
service,
|
|
9454
12006
|
config,
|
|
9455
12007
|
tmux,
|
|
9456
|
-
terminalMetadata
|
|
12008
|
+
terminalMetadata,
|
|
12009
|
+
browserSessions
|
|
9457
12010
|
});
|
|
9458
12011
|
await new Promise((resolve, reject) => {
|
|
9459
12012
|
server.once("error", reject);
|
|
@@ -9483,6 +12036,7 @@ async function main() {
|
|
|
9483
12036
|
terminalMetadata.drain(),
|
|
9484
12037
|
viteClosed
|
|
9485
12038
|
]).then(async () => {
|
|
12039
|
+
await browserSessions.dispose();
|
|
9486
12040
|
await service.disposeWebPanelRuntime();
|
|
9487
12041
|
database.close();
|
|
9488
12042
|
await ownership.release();
|